32 Commits
Author SHA1 Message Date
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
92 changed files with 5124 additions and 1744 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 .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/ var/
*.sqlite
*.sqlite3
*.db
# ── Rebuilt inside the image from composer.lock ─────────────────────────────
vendor/ vendor/
# ── Dev / test artefacts not needed at runtime ──────────────────────────────
tests/ tests/
.phpunit.cache/ .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/ docs/
*.md *.md
.env license.txt
.env.test Caddyfile
.env.local Domainfile
run.sh
deploy.sh
bin/composer
bin/dev.sh
bin/franken.sh
bin/phpunit
composer.phar 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 # 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 root = true
[*] [*]
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
indent_size = 4
indent_style = space indent_style = space
indent_size = 4
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = 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] [Caddyfile]
indent_style = tab indent_style = tab
[{compose.yaml,compose.*.yaml}] [Makefile]
indent_size = 2 indent_style = tab
[*.md] [*.{sh,bash}]
trim_trailing_whitespace = false indent_size = 4
+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 name: Push Develop
on: on:
push: push:
branches: branches:
- 'main' - 'main'
- 'develop'
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
with:
mode: develop
steps: # Passed explicitly from repo vars
- name: Checkout image-target: ${{ vars.DOCKERHUB_TARGET }}
uses: actions/checkout@v4
- name: Setup Buildx # docker-bake.hcl controls building
uses: docker/setup-buildx-action@v3 build-backend: 'bake'
- name: Login to Docker Hub secrets:
uses: docker/login-action@v3 DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with: DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
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
+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 name: Push Docker
on: on:
@@ -7,31 +11,16 @@ on:
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest uses: private/ci/.gitea/workflows/docker-publish.yaml@v1
with:
mode: release
steps: # Passed explicitly from repo vars
- name: Checkout image-target: ${{ vars.DOCKERHUB_TARGET }}
uses: actions/checkout@v4
- name: Setup Buildx # docker-bake.hcl controls building
uses: docker/setup-buildx-action@v3 build-backend: 'bake'
- name: Login to Docker Hub secrets:
uses: docker/login-action@v3 DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with: DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
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 }}
+14 -27
View File
@@ -1,37 +1,24 @@
# Sync GitHub - upload branch change to github, via the shared workflow
name: Sync GitHub name: Sync GitHub
on: on:
push: push:
branches: branches:
- '**' - 'main'
- 'feat*'
- 'fix*'
- 'cleanup*'
- 'chore*'
jobs: jobs:
sync: sync:
runs-on: ubuntu-latest uses: private/ci/.gitea/workflows/sync-github.yaml@v1
with:
sync-target: ${{ vars.SYNC_GITHUB_TARGET }}
steps: # by default we do not alter existing github tags, but that can be changed here.
- name: Checkout # force-tags: true
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
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 name: Tests
on: on:
push: push:
branches: branches:
- 'main' - 'main'
- 'develop' - 'feat*'
- 'fix*'
- 'cleanup*'
- 'chore*'
pull_request: pull_request:
branches: branches:
- 'main' - 'main'
- 'develop'
jobs: jobs:
test: test:
runs-on: ubuntu-latest uses: private/ci/.gitea/workflows/php-test.yaml@v1
with:
php-version: '8.5'
steps: # profiles defines what to test:
- name: Checkout # * web-app: full test suite (default)
uses: actions/checkout@v4 # * auth-gateway: skip template check
# * api-gateway: skip interface checks
profile: auth-gateway
- name: Setup PHP # coverage defines how to check test-coverage:
uses: shivammathur/setup-php@v2 # * pcov: recommended (default)
with: # * xdebug
php-version: '8.5' coverage: 'pcov'
extensions: apcu, mbstring # 0-100 percentage of test-coverage required
coverage: xdebug coverage-min: '75'
ini-values: apc.enable_cli=1
- name: Install dependencies # does failing our "conformance" check make the test suite fail
run: composer install --prefer-dist --no-progress conformance-blocking: false
- name: Run php-cs-fixer secrets:
run: vendor/bin/php-cs-fixer fix --dry-run --diff # github token so composer can download dependencies
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
- name: Run tests
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
+68 -13
View File
@@ -1,18 +1,73 @@
<?php <?php
$finder = (new PhpCsFixer\Finder()) declare(strict_types=1);
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->notPath([
'config/bundles.php',
'config/reference.php',
])
;
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([ ->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)
);
+98
View File
@@ -25,6 +25,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `PublicPathMatcher` service for path pattern matching. - New `PublicPathMatcher` service for path pattern matching.
- New `PublicAccessListener` (priority 84) in the request pipeline. - 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 ## [1.0.0] — v1.0 Release
### Security ### Security
+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:// http://
root public/ root public/
rewrite index.php 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 FROM php:8.5-trixie AS build
# install APCu and composer # Build-time set: git (composer resolves packages over VCS) and unzip (dist
RUN pecl install apcu && \ # extraction). Neither reaches the runtime image.
docker-php-ext-enable apcu RUN apt-get update \
COPY --from=composer /usr/bin/composer /usr/bin/composer && apt-get install -y --no-install-recommends git unzip \
RUN apt-get update && \ && rm -rf /var/lib/apt/lists/*
apt-get install -y unzip git
# symfony required environment variables # APCu and Composer, both only needed to compile the application.
ENV APP_DEBUG=0 RUN pecl install apcu \
ENV APP_ENV=prod && docker-php-ext-enable apcu
ENV APP_SHARE_DIR=/data/preauth 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 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 # Manifests first so the dependency layer only rebuilds when they change.
RUN composer install --no-dev --optimize-autoloader COPY composer.json composer.lock symfony.lock ./
RUN composer dump-env prod --empty RUN composer install --no-dev --no-interaction --prefer-dist \
--optimize-autoloader --no-scripts
# start creating final image # Copy the application. .dockerignore keeps vendor/, var/, tests/ and the
FROM dunglas/frankenphp:php8.5-trixie # local env files out of the context; composer install has already run, so
# its vendor/ wins.
COPY . .
# install APCu and curl (needed for healthcheck) # src/ was not in the context when composer install ran, so the authoritative
RUN pecl install apcu && \ # classmap has to be rebuilt now that the application code is present.
docker-php-ext-enable apcu #
RUN apt-get update && \ # There is deliberately no `composer dump-env` step: preauth does not depend
apt-get install -y --no-install-recommends curl && \ # on symfony/dotenv (it is absent from composer.lock), so nothing reads a
rm -rf /var/lib/apt/lists/* # .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 # Build-time smoke of the autoloader + config compile. No APP_SECRET is
ENV APP_DEBUG=0 # needed: %env(APP_SECRET)% is not resolved at compile time, and the cache is
ENV APP_ENV=prod # cleared afterwards anyway — the real warm-up runs at container start with
ENV APP_SHARE_DIR=/data/preauth # 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 WORKDIR /app
COPY --from=build /data/preauth /data/preauth
COPY --from=build /app /app COPY --from=build /app /app
# configure container # Non-root runtime user (Guiding Light §6.4). uid/gid 1000, same convention
COPY ./Caddyfile /etc/frankenphp/Caddyfile # as task-loom/task-weaver/context-shuttle. /data holds the cache pools the
RUN cp $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini # app writes at runtime, /config is Caddy's own XDG dir.
RUN echo 'expose_php = off' > $PHP_INI_DIR/conf.d/restrict.ini RUN groupadd --system --gid 1000 app \
# console needs apc to manage cache && useradd --system --uid 1000 --gid app \
RUN echo 'apc.enable_cli = on' > $PHP_INI_DIR/conf.d/console.ini --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"] VOLUME ["/config", "/data"]
# runs http on standard port
EXPOSE 80 EXPOSE 80
# healthcheck # Liveness: Caddy's own admin endpoint, bound to loopback inside the
HEALTHCHECK --interval=5m \ # container, exactly as the base image declares it (restated here so the
--retries=3 \ # probe does not depend on the upstream default staying put). The app's own
--start-interval=1s \ # routes cannot serve this: an unauthenticated request gets the login page
--start-period=10s \ # with a 401, so `curl -f` against HTTP would always report unhealthy.
--timeout=2s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl http://localhost || exit 1 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.
+2 -2
View File
@@ -10,7 +10,7 @@ authentication — it's a gate that prevents outsiders from even seeing
what service is running. what service is running.
- **Location:** `projects/preauth/` - **Location:** `projects/preauth/`
- **Framework:** Symfony 7.4 (PHP ≥ 8.4) - **Framework:** Symfony 8.1 (PHP ≥ 8.4)
- **Serving:** FrankenPHP (Docker image) - **Serving:** FrankenPHP (Docker image)
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence - **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
- **Auth:** TOTP (single secret) + single-use backup codes - **Auth:** TOTP (single secret) + single-use backup codes
@@ -329,7 +329,7 @@ struggle with TOTP apps.
command or initial-setup flow to register a passkey). command or initial-setup flow to register a passkey).
- [ ] Research `web-auth/webauthn-framework` integration with Symfony - [ ] Research `web-auth/webauthn-framework` integration with Symfony
7.4 and FrankenPHP 8.1 and FrankenPHP
- [ ] Design passkey registration flow (console command? first-visit - [ ] Design passkey registration flow (console command? first-visit
setup? separate registration endpoint?) setup? separate registration endpoint?)
- [ ] Implement challenge generation and storage (extend existing - [ ] 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.
+1 -1
View File
@@ -46,7 +46,7 @@ REQUIRED_PHP_EXTS=(
) )
# Apt packages for PHP + extensions # Apt packages for PHP + extensions
# Note: preauth uses Symfony 7.4 which requires PHP >=8.1. # Note: preauth uses Symfony 8.1 which requires PHP >=8.4.
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency. # We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
PHP_APT_PACKAGES=( PHP_APT_PACKAGES=(
php8.4-cli php8.4-cli
+1 -3
View File
@@ -9,8 +9,6 @@ docker run --name preauth \
-e APP_ENV=dev \ -e APP_ENV=dev \
-e APP_DEBUG=true \ -e APP_DEBUG=true \
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \ -e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
-e APP_SHARE_DIR=var/share \ -e APP_SHARE_DIR=/app/var/share \
-e DEFAULT_URI=http://localhost \
-v ./var/share:/app/var/share \
-p 8000:80 \ -p 8000:80 \
digitaladapt/preauth:dev digitaladapt/preauth:dev
+19 -19
View File
@@ -4,22 +4,21 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.4", "php": "^8.5",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"bacon/bacon-qr-code": "^3.1.1", "bacon/bacon-qr-code": "^3.1.1",
"runtime/frankenphp-symfony": "^1.0.0",
"spomky-labs/otphp": "^11.4.2", "spomky-labs/otphp": "^11.4.2",
"symfony/cache": "7.4.*", "symfony/cache": "8.1.*",
"symfony/console": "7.4.*", "symfony/console": "8.1.*",
"symfony/flex": "^2.11", "symfony/flex": "^2.11",
"symfony/framework-bundle": "7.4.*", "symfony/framework-bundle": "8.1.*",
"symfony/mime": "7.4.*", "symfony/mime": "8.1.*",
"symfony/rate-limiter": "7.4.*", "symfony/rate-limiter": "8.1.*",
"symfony/runtime": "7.4.*", "symfony/runtime": "8.1.*",
"symfony/twig-bundle": "7.4.*", "symfony/twig-bundle": "8.1.*",
"symfony/uid": "7.4.*", "symfony/uid": "8.1.*",
"symfony/yaml": "7.4.*" "symfony/yaml": "8.1.*"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@@ -28,7 +27,10 @@
"symfony/runtime": true "symfony/runtime": true
}, },
"bump-after-update": true, "bump-after-update": true,
"sort-packages": true "sort-packages": true,
"platform": {
"php": "8.5.0"
}
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -66,18 +68,16 @@
"symfony/symfony": "*" "symfony/symfony": "*"
}, },
"extra": { "extra": {
"runtime": {
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
},
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.4.*" "require": "8.1.*"
} }
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "*", "friendsofphp/php-cs-fixer": "^3.95",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^13.2", "phpunit/phpunit": "^13.2",
"symfony/browser-kit": "7.4.*", "symfony/browser-kit": "8.1.*",
"symfony/css-selector": "7.4.*" "symfony/css-selector": "8.1.*"
} }
} }
Generated
+787 -807
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
+4 -2
View File
@@ -1,8 +1,10 @@
<?php <?php
if (file_exists(dirname(__DIR__) . declare(strict_types=1);
if (file_exists(dirname(__DIR__).
'/var/cache/prod/App_KernelProdContainer.preload.php') '/var/cache/prod/App_KernelProdContainer.preload.php')
) { ) {
require dirname(__DIR__) . require dirname(__DIR__).
'/var/cache/prod/App_KernelProdContainer.preload.php'; '/var/cache/prod/App_KernelProdContainer.preload.php';
} }
+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 = "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
@@ -24,6 +24,14 @@
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests" # once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
#TEAPOT=true # default enabled, boolean #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 --- # --- remote-user header ---
# Controls the value sent in the Remote-User header on successful auth. # Controls the value sent in the Remote-User header on successful auth.
# session: the session id (default, backward-compatible) # session: the session id (default, backward-compatible)
@@ -1,9 +1,34 @@
# 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 # example of securing full service
# TODO replace domain and service name and port # TODO replace domain and service name and port
service.example.com { service.example.com {
forward_auth preauth { forward_auth preauth {
uri {uri} uri {uri}
copy_headers Remote-User copy_headers Remote-User
import preauth_no_store
} }
reverse_proxy service-container:80 reverse_proxy service-container:80
} }
@@ -16,6 +41,7 @@ protected.example.com {
forward_auth /secure/* preauth { forward_auth /secure/* preauth {
uri {uri} uri {uri}
copy_headers Remote-User copy_headers Remote-User
import preauth_no_store
} }
reverse_proxy protected-service:9000 reverse_proxy protected-service:9000
} }
@@ -39,6 +65,7 @@ git.example.com {
forward_auth preauth { forward_auth preauth {
uri {uri} uri {uri}
copy_headers Remote-User copy_headers Remote-User
import preauth_no_store
} }
reverse_proxy gitea:3000 reverse_proxy gitea:3000
} }
@@ -1,7 +1,8 @@
services: services:
preauth: preauth:
env_file: 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 # strongly recommend setting TOTP_URI, if not provided the app
# will generate one for you, please copy it into your .env file # will generate one for you, please copy it into your .env file
- .env - .env
@@ -9,8 +10,10 @@ services:
- 80 - 80
image: digitaladapt/preauth:latest image: digitaladapt/preauth:latest
restart: unless-stopped restart: unless-stopped
# if you wish to set the user, you must make sure that the user # The image runs as the non-root `app` user (uid/gid 1000) and creates
# can write to /config and /data within the container # 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> #user: <uid>:<gid>
volumes: volumes:
- preauth-config:/config - preauth-config:/config
@@ -19,4 +22,3 @@ services:
volumes: volumes:
preauth-config: preauth-config:
preauth-data: preauth-data:
+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.
+985
View File
@@ -0,0 +1,985 @@
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\\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\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
identifier: property.uninitializedReadonly
count: 1
path: src/Service/LoginManager.php
-
message: '#^Method App\\Service\\LoginManager\:\:checkToken\(\) overrides method App\\Service\\LoginInterface\:\:checkToken\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
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: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/ConfigBagRemoteUserTest.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: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/AcceptListenerTest.php
-
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Listener/AllowListenerTest.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 Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) 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\:\: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 Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) 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\:\: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 Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) 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\:\: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 Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) 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\:\: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: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
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: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
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: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
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: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
identifier: method.missingOverride
count: 1
path: tests/Unit/Trait/StringTraitTest.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
# ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -6,6 +6,6 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; 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']); return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
}; };
+25 -5
View File
@@ -44,7 +44,7 @@ docker pull digitaladapt/preauth:latest
openssl rand -base64 30 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 ```env
APP_SECRET=your-random-secret-here APP_SECRET=your-random-secret-here
@@ -61,7 +61,7 @@ COOKIE_TTL=2592000
docker compose up -d 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 ### 4. Configure Caddy
@@ -70,13 +70,24 @@ service.example.com {
forward_auth preauth { forward_auth preauth {
uri {uri} uri {uri}
copy_headers Remote-User 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 reverse_proxy your-service:80
} }
``` ```
See `docs/Caddyfile` for more examples, including path-specific protection See `docs/examples/Caddyfile` for more examples, including path-specific protection
and central auth subdomain configuration. 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) ### 5. Generate backup codes (optional)
@@ -95,7 +106,7 @@ capabilities may work, but only Caddy is officially supported.
## Configuration ## 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. for the complete reference.
### Main Options ### Main Options
@@ -113,6 +124,7 @@ for the complete reference.
|----------|---------|-------------| |----------|---------|-------------|
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). | | `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). | | `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 ### Remote-User Header
@@ -236,6 +248,14 @@ passes through a priority-ordered chain of listeners:
- **Rate limiting**: Per-IP, compound sliding window, cannot be disabled - **Rate limiting**: Per-IP, compound sliding window, cannot be disabled
- **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options, - **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, HSTS 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 ### Cache
+3 -2
View File
@@ -9,10 +9,10 @@ use App\Service\BackupCodeInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; 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\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
/** simple console command to generate backup codes /** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */ * usage: php bin/console app:generate-backup-codes [count] */
@@ -21,7 +21,7 @@ final class GenerateBackupCodesCommand extends Command
{ {
public function __construct( public function __construct(
private readonly BackupCodeInterface $manager, private readonly BackupCodeInterface $manager,
private readonly PersistCache $persistCache, private readonly PersistCache $persistCache,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -46,6 +46,7 @@ final class GenerateBackupCodesCommand extends Command
$output->writeln($code); $output->writeln($code);
} }
$this->persistCache->persist(); $this->persistCache->persist();
return Command::SUCCESS; return Command::SUCCESS;
} }
} }
+20 -19
View File
@@ -26,31 +26,31 @@ final readonly class ConfigBag
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
public function __construct( public function __construct(
Utilities $utilities, Utilities $utilities,
ClockInterface $clock, ClockInterface $clock,
#[Autowire('%app.cookie_ttl%')] int $cookieTtl, #[Autowire('%app.cookie_ttl%')] int $cookieTtl,
#[Autowire('%app.totp_uri%')] string $totpUri, #[Autowire('%app.totp_uri%')] string $totpUri,
#[Autowire('%app.ip_ttl%')] ?int $ipTtl, #[Autowire('%app.ip_ttl%')] ?int $ipTtl,
#[Autowire('%app.teapot%')] bool $teapot, #[Autowire('%app.teapot%')] bool $teapot,
#[Autowire('%app.error_message%')] string $errorMessage, #[Autowire('%app.error_message%')] string $errorMessage,
#[Autowire('%app.teapot_title%')] string $teapotTitle, #[Autowire('%app.teapot_title%')] string $teapotTitle,
#[Autowire('%app.too_many_title%')] string $tooManyTitle, #[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_static%')] string $remoteUserStatic,
#[Autowire('%app.remote_user_map%')] string $remoteUserMap, #[Autowire('%app.remote_user_map%')] string $remoteUserMap,
) { ) {
$this->clock = $clock; $this->clock = $clock;
$this->cookieTtl = $cookieTtl; $this->cookieTtl = $cookieTtl;
$this->totpUri = $totpUri ?: $utilities->loadTotp(); $this->totpUri = $totpUri ?: $utilities->loadTotp();
$this->ipTtl = $ipTtl ?: null; $this->ipTtl = $ipTtl ?: null;
$this->teapot = $teapot; $this->teapot = $teapot;
$this->errorMessage = $errorMessage; $this->errorMessage = $errorMessage;
$this->teapotTitle = $teapotTitle; $this->teapotTitle = $teapotTitle;
$this->tooManyTitle = $tooManyTitle; $this->tooManyTitle = $tooManyTitle;
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session; $this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
$this->remoteUserStatic = $remoteUserStatic; $this->remoteUserStatic = $remoteUserStatic;
$this->remoteUserMap = $this->parseUserMap($remoteUserMap); $this->remoteUserMap = $this->parseUserMap($remoteUserMap);
} }
/** /**
@@ -60,17 +60,18 @@ final readonly class ConfigBag
*/ */
private function parseUserMap(string $map): array private function parseUserMap(string $map): array
{ {
if ($map === '') { if ('' === $map) {
return []; return [];
} }
$result = []; $result = [];
foreach (explode(',', $map) as $pair) { foreach (explode(',', $map) as $pair) {
$parts = explode(':', trim($pair), 2); $parts = explode(':', trim($pair), 2);
if (count($parts) === 2) { if (2 === \count($parts)) {
$result[trim($parts[0])] = trim($parts[1]); $result[trim($parts[0])] = trim($parts[1]);
} }
} }
return $result; return $result;
} }
+22 -20
View File
@@ -14,59 +14,61 @@ final class Payload
public string $id; /* session name, identifying who is logging in */ public string $id; /* session name, identifying who is logging in */
public string $token; /* TOTP, typically six digits */ public string $token; /* TOTP, typically six digits */
public string $nonce; /* random unique string, to block duplicate submissions */ public string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */ public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */ 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 */ /* convert the base64url into json string */
$base64 = strtr($base64url, '-_', '+/'); $base64 = strtr($base64url, '-_', '+/');
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4); $base64 .= str_repeat('=', (4 - \strlen($base64) % 4) % 4);
$json = base64_decode($base64, true); $json = base64_decode($base64, true);
if ($json) { if ($json) {
/* convert the json string into real data */ /* convert the json string into real data */
$data = json_decode($json); $data = json_decode($json);
if (is_object($data)) { if (\is_object($data)) {
return Payload::create($data); return self::create($data);
} }
} }
return null; return null;
} }
public static function load(InputBag $input): ?Payload public static function load(InputBag $input): ?self
{ {
/* convert form data into real data */ /* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) { if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[ return self::create((object) [
'id' => $input->get('username'), 'id' => $input->get('username'),
'nonce' => $input->get('nonce'), 'nonce' => $input->get('nonce'),
'token' => $input->get('totp'), 'token' => $input->get('totp'),
'json' => false, 'json' => false,
]); ]);
} }
return null; 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 missing required fields id, nonce, or token */
if (strlen(trim($data->id ?? '')) < 1 || if ('' === trim($data->id ?? '')
strlen(trim($data->nonce ?? '')) < 1 || || '' === trim($data->nonce ?? '')
strlen(trim($data->token ?? '')) < 1 || '' === trim($data->token ?? '')
) { ) {
/* returns null as the input is invalid */ /* returns null as the input is invalid */
return null; return null;
} }
/* all input is limited */ /* all input is limited */
$payload = new Payload(); $payload = new self();
$payload->id = mb_substr(trim($data->id), 0, AppConstants::MAX_INPUT_LENGTH); $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->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->scope = Scope::tryFrom($data->scope ?? '') ?? Scope::Cookie;
$payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH); $payload->token = mb_substr(trim($data->token), 0, AppConstants::MAX_INPUT_LENGTH);
return Payload::constrict($payload); return self::constrict($payload);
} }
public function toString(): string public function toString(): string
@@ -74,10 +76,10 @@ final class Payload
return json_encode($this); 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. */ /* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) { if (Scope::None === $payload->scope) {
$payload->json = false; $payload->json = false;
} }
+2 -2
View File
@@ -8,6 +8,6 @@ namespace App\Enum;
enum Scope: string enum Scope: string
{ {
case Cookie = 'cookie'; case Cookie = 'cookie';
case Ip = 'ip'; case Ip = 'ip';
case None = 'none'; case None = 'none';
} }
+7 -6
View File
@@ -11,6 +11,7 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -21,9 +22,9 @@ final readonly class AcceptListener
use StringTrait; use StringTrait;
public function __construct( public function __construct(
private CacheItemPoolInterface $sessionCache, #[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager, private DomainInterface $domainManager,
private ConfigBag $config, private ConfigBag $config,
) { ) {
} }
@@ -32,7 +33,7 @@ final readonly class AcceptListener
{ {
/* check if they sent the correct preauth cookie */ /* check if they sent the correct preauth cookie */
$cookieName = $this->sessionCookieName($this->domainManager); $cookieName = $this->sessionCookieName($this->domainManager);
if (! $event->getRequest()->cookies->has($cookieName)) { if (!$event->getRequest()->cookies->has($cookieName)) {
return; return;
} }
@@ -40,13 +41,13 @@ final readonly class AcceptListener
$cookieKey = $this->makeCacheKey("cookie_$cookie"); $cookieKey = $this->makeCacheKey("cookie_$cookie");
try { try {
if (! $cookie || ! $this->sessionCache->hasItem($cookieKey)) { if (!$cookie || !$this->sessionCache->hasItem($cookieKey)) {
return; return;
} }
/* cookie sent corresponds to valid existing session */ /* cookie sent corresponds to valid existing session */
$item = $this->sessionCache->getItem($cookieKey); $item = $this->sessionCache->getItem($cookieKey);
if (! $item->isHit()) { if (!$item->isHit()) {
/* race condition: item was removed between hasItem and getItem */ /* race condition: item was removed between hasItem and getItem */
return; return;
} }
+5 -4
View File
@@ -9,6 +9,7 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -18,8 +19,8 @@ final readonly class AllowListener
use StringTrait; use StringTrait;
public function __construct( public function __construct(
private CacheItemPoolInterface $sessionCache, #[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
private ConfigBag $config, private ConfigBag $config,
) { ) {
} }
@@ -33,13 +34,13 @@ final readonly class AllowListener
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}"); $ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
try { try {
if (! $this->sessionCache->hasItem($ipKey)) { if (!$this->sessionCache->hasItem($ipKey)) {
return; return;
} }
/* ip address corresponds to valid existing session */ /* ip address corresponds to valid existing session */
$item = $this->sessionCache->getItem($ipKey); $item = $this->sessionCache->getItem($ipKey);
if (! $item->isHit()) { if (!$item->isHit()) {
/* race condition: item was removed between hasItem and getItem */ /* race condition: item was removed between hasItem and getItem */
return; return;
} }
+9 -9
View File
@@ -26,9 +26,9 @@ final readonly class InterceptListener
use MakeNonceTrait; use MakeNonceTrait;
public function __construct( public function __construct(
private ConfigBag $config, private ConfigBag $config,
private DomainInterface $domainManager, 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: /* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited, * not already authorized, nor already rate-limited,
* nor submitting login credentials; so redirect or present the login page now */ * nor submitting login credentials; so redirect or present the login page now */
if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost() && if ($this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost()
$this->domainManager->matchesAuth($event->getRequest()->getHost()) && $this->domainManager->matchesAuth($event->getRequest()->getHost())
) { ) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */ /* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]); $query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response( $event->setResponse(new Response(
'', '',
Response::HTTP_SEE_OTHER, Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"] ['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"],
)); ));
} else { } else {
$this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}"); $this->logger->debug("presenting login page: {$event->getRequest()->getClientIp()}");
$content = $this->twig->render('login.html.twig', [ $content = $this->twig->render('login.html.twig', [
'nonce' => $this->makeNonce(), 'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(), 'post' => $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost(),
]); ]);
$hasCookie = (bool) $event->getRequest()->cookies->get( $hasCookie = (bool) $event->getRequest()->cookies->get(
$this->sessionCookieName($this->domainManager) $this->sessionCookieName($this->domainManager),
); );
$event->setResponse($this->pruneInvalidCookie(new Response( $event->setResponse($this->pruneInvalidCookie(new Response(
$content, $content,
Response::HTTP_UNAUTHORIZED, Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html'],
), $hasCookie, $event->getRequest()->getHost())); ), $hasCookie, $event->getRequest()->getHost()));
} }
} }
@@ -75,7 +75,7 @@ final readonly class InterceptListener
$this->sessionCookieDomain($this->domainManager, $host), $this->sessionCookieDomain($this->domainManager, $host),
true, true,
true, true,
Cookie::SAMESITE_STRICT Cookie::SAMESITE_STRICT,
); );
} }
+18 -16
View File
@@ -43,28 +43,28 @@ final readonly class LoginListener
private RateLimiterFactoryInterface $rateLimiter; private RateLimiterFactoryInterface $rateLimiter;
public function __construct( public function __construct(
private Environment $twig, private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager, private DomainInterface $domainManager,
private LoginInterface $loginManager, private LoginInterface $loginManager,
private ConfigBag $config, private ConfigBag $config,
) { ) {
$this->rateLimiter = $rateLimiter; $this->rateLimiter = $rateLimiter;
} }
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */ /** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)] #[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void public function onKernelRequest(RequestEvent $event): void
{ {
$payload = null; $payload = null;
$response = null; $response = null;
if ($event->getRequest()->headers->has($this->headerName())) { if ($event->getRequest()->headers->has($this->headerName())) {
/* if request contains our "X-Preauth" header */ /* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName()); $data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data); $payload = Payload::decode($data);
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) && } elseif ($event->getRequest()->isMethod(Request::METHOD_POST)
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost() && $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
) { ) {
/* if request is a POST to the auth-subdomain */ /* if request is a POST to the auth-subdomain */
$payload = Payload::load($event->getRequest()->getPayload()); $payload = Payload::load($event->getRequest()->getPayload());
@@ -80,6 +80,7 @@ final readonly class LoginListener
/* token or backup-code authentication was successful */ /* token or backup-code authentication was successful */
if ($response) { if ($response) {
$event->setResponse($response); $event->setResponse($response);
return; return;
} }
} }
@@ -92,14 +93,15 @@ final readonly class LoginListener
$limitReached, $limitReached,
$payload?->json ?? true, $payload?->json ?? true,
$event->getRequest()->getHost(), $event->getRequest()->getHost(),
$this->makeCacheKey($payload?->id ?? '') $this->makeCacheKey($payload?->id ?? ''),
)); ));
} }
private function logFailure(Request $request): bool private function logFailure(Request $request): bool
{ {
$limiter = $this->rateLimiter->create($request->getClientIp()); $limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
return $limiter->consume(1)->getRemainingTokens() < 1;
} }
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ /** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
@@ -111,24 +113,24 @@ final readonly class LoginListener
$message = $this->config->teapot() ? $this->config->teapotTitle() $message = $this->config->teapot() ? $this->config->teapotTitle()
: $this->config->tooManyTitle(); : $this->config->tooManyTitle();
} else { } else {
$status = Response::HTTP_UNAUTHORIZED; $status = Response::HTTP_UNAUTHORIZED;
$message = $this->config->errorMessage(); $message = $this->config->errorMessage();
} }
$answer = [ $answer = [
'message' => $message, 'message' => $message,
'nonce' => $this->makeNonce(), 'nonce' => $this->makeNonce(),
'post' => $this->domainManager->getAuthSubdomain() === $host, 'post' => $this->domainManager->getAuthSubdomain() === $host,
'username' => $username, 'username' => $username,
]; ];
if ($json) { if ($json) {
$contentType = 'application/json'; $contentType = 'application/json';
$content = json_encode($answer); $content = json_encode($answer);
} else { } else {
$contentType = 'text/html'; $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]);
} }
} }
+6 -6
View File
@@ -39,8 +39,8 @@ final readonly class PublicAccessListener
public function __construct( public function __construct(
private PublicPathMatcherInterface $pathMatcher, private PublicPathMatcherInterface $pathMatcher,
private DomainInterface $domainManager, private DomainInterface $domainManager,
private Environment $twig, private Environment $twig,
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter, #[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
) { ) {
$this->rateLimiter = $rateLimiter; $this->rateLimiter = $rateLimiter;
@@ -63,7 +63,7 @@ final readonly class PublicAccessListener
return; return;
} }
if (! $this->pathMatcher->matches($host, $path)) { if (!$this->pathMatcher->matches($host, $path)) {
return; return;
} }
@@ -77,8 +77,8 @@ final readonly class PublicAccessListener
'', '',
Response::HTTP_OK, Response::HTTP_OK,
[ [
'Content-Type' => 'text/plain', 'Content-Type' => 'text/plain',
'Retry-After' => (string) $limit->getRemainingTokens(), 'Retry-After' => (string) $limit->getRemainingTokens(),
], ],
)); ));
} else { } else {
@@ -92,7 +92,7 @@ final readonly class PublicAccessListener
Response::HTTP_TOO_MANY_REQUESTS, Response::HTTP_TOO_MANY_REQUESTS,
[ [
'Content-Type' => 'text/html', 'Content-Type' => 'text/html',
'Retry-After' => (string) $retryAfter, 'Retry-After' => (string) $retryAfter,
], ],
)); ));
} }
+6 -6
View File
@@ -9,8 +9,8 @@ use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment; use Twig\Environment;
use Twig\Error\LoaderError; use Twig\Error\LoaderError;
@@ -25,8 +25,8 @@ final readonly class RejectListener
private RateLimiterFactoryInterface $rateLimiter; private RateLimiterFactoryInterface $rateLimiter;
public function __construct( public function __construct(
private ConfigBag $config, private ConfigBag $config,
private Environment $twig, private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter, #[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) { ) {
$this->rateLimiter = $rateLimiter; $this->rateLimiter = $rateLimiter;
@@ -43,9 +43,9 @@ final readonly class RejectListener
$html = $this->twig->render('error.html.twig'); $html = $this->twig->render('error.html.twig');
$event->setResponse(new Response( $event->setResponse(new Response(
$html, $html,
($this->config->teapot() $this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS), ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html'],
)); ));
} }
} }
+46 -7
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Listener; namespace App\Listener;
use App\Service\DomainInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent;
@@ -15,15 +16,20 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
*/ */
final readonly class SecurityHeadersListener final readonly class SecurityHeadersListener
{ {
public function __construct(
private DomainInterface $domainManager,
) {
}
#[AsEventListener(priority: 0)] #[AsEventListener(priority: 0)]
public function onKernelResponse(ResponseEvent $event): void public function onKernelResponse(ResponseEvent $event): void
{ {
if (! $event->isMainRequest()) { if (!$event->isMainRequest()) {
return; return;
} }
$response = $event->getResponse(); $response = $event->getResponse();
$headers = $response->headers; $headers = $response->headers;
/* prevent MIME-type sniffing */ /* prevent MIME-type sniffing */
$headers->set('X-Content-Type-Options', 'nosniff'); $headers->set('X-Content-Type-Options', 'nosniff');
@@ -36,13 +42,46 @@ final readonly class SecurityHeadersListener
/* Content-Security-Policy — the login page uses inline styles /* Content-Security-Policy — the login page uses inline styles
* and scripts (via Twig includes), so we allow 'unsafe-inline' * and scripts (via Twig includes), so we allow 'unsafe-inline'
* for those. No external resources are loaded. */ * for those. No external resources are loaded.
$headers->set( *
'Content-Security-Policy', * When subdomain redirection is off (or the request is not on
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';" * 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';";
if ($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) */ /* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
$headers->set('Strict-Transport-Security', 'max-age=31536000'); $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. */
if (!$response->isSuccessful()) {
$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; $this->cache = $cache;
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]); $items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) { foreach ($items as $item) {
if (! $item->isHit()) { if (!$item->isHit()) {
$this->initialize(); $this->initialize();
break; break;
} }
@@ -49,6 +49,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function getKeys(): array public function getKeys(): array
{ {
$keyList = $this->cache->getItem(self::KEY_LIST); $keyList = $this->cache->getItem(self::KEY_LIST);
return array_keys($keyList->get() ?? []); return array_keys($keyList->get() ?? []);
} }
@@ -56,6 +57,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function getChanges(): array public function getChanges(): array
{ {
$changeList = $this->cache->getItem(self::CHANGE_LIST); $changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? []; return $changeList->get() ?? [];
} }
@@ -90,12 +92,14 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function clear(): bool public function clear(): bool
{ {
/* only bother clearing the pool if it is not empty */ /* only bother clearing the pool if it is not empty */
if (! empty($this->getKeys())) { if (!empty($this->getKeys())) {
$response = $this->cache->clear(); $response = $this->cache->clear();
$this->initialize(); $this->initialize();
return $response; return $response;
} }
return true; return true;
} }
@@ -109,7 +113,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
unset($keyValues[$key]); unset($keyValues[$key]);
$keyList->set($keyValues); $keyList->set($keyValues);
$this->cache->saveDeferred($keyList); $this->cache->saveDeferred($keyList);
$this->logChange($key, MonitorCacheKeys::REMOVED); $this->logChange($key, self::REMOVED);
$this->cache->commit(); $this->cache->commit();
} }
@@ -125,7 +129,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
foreach ($keys as $key) { foreach ($keys as $key) {
if (isset($keyValues[$key])) { if (isset($keyValues[$key])) {
unset($keyValues[$key]); unset($keyValues[$key]);
$this->logChange($key, MonitorCacheKeys::REMOVED); $this->logChange($key, self::REMOVED);
} }
} }
$keyList->set($keyValues); $keyList->set($keyValues);
@@ -139,6 +143,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function save(CacheItemInterface $item): bool public function save(CacheItemInterface $item): bool
{ {
$this->update($item); $this->update($item);
return $this->cache->save($item); return $this->cache->save($item);
} }
@@ -146,6 +151,7 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
public function saveDeferred(CacheItemInterface $item): bool public function saveDeferred(CacheItemInterface $item): bool
{ {
$this->update($item); $this->update($item);
return $this->cache->saveDeferred($item); return $this->cache->saveDeferred($item);
} }
@@ -171,27 +177,23 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface
/** @throws OutOfBoundsException */ /** @throws OutOfBoundsException */
private function isValid(string $key): void private function isValid(string $key): void
{ {
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) { if (self::KEY_LIST === $key || self::CHANGE_LIST === $key) {
throw new OutOfBoundsException( throw new OutOfBoundsException('Can not modify the private key or change lists');
'Can not modify the private key or change lists'
);
} }
} }
/** @throws OutOfBoundsException */ /** @throws OutOfBoundsException */
private function allValid(array $keys): void private function allValid(array $keys): void
{ {
if (in_array(self::KEY_LIST, $keys, true) || if (\in_array(self::KEY_LIST, $keys, true)
in_array(self::CHANGE_LIST, $keys, true) || \in_array(self::CHANGE_LIST, $keys, true)
) { ) {
throw new OutOfBoundsException( throw new OutOfBoundsException('Can not modify the private key or change lists');
'Can not modify the private key or change lists'
);
} }
} }
/** @throws InvalidArgumentException */ /** @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); $changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeValues = $changeList->get(); $changeValues = $changeList->get();
+4 -3
View File
@@ -7,6 +7,7 @@ namespace App;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; 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() */ /* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)] #[Autoconfigure(public: true)]
@@ -17,10 +18,10 @@ final readonly class PersistCache
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
public function __construct( public function __construct(
CacheItemPoolInterface $sessionCache, #[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionStorage, #[Target('sessionStorage')] CacheItemPoolInterface $sessionStorage,
) { ) {
$this->sessionCache = new MonitorCacheKeys($sessionCache); $this->sessionCache = new MonitorCacheKeys($sessionCache);
$this->sessionStorage = new MonitorCacheKeys($sessionStorage); $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 */ * they are single-use and marked as used after successful authentication */
interface BackupCodeInterface 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 * @param int $count Number of codes to generate
*
* @return string[] Generated backup codes * @return string[] Generated backup codes
*
* @throws InvalidArgumentException|Exception */ * @throws InvalidArgumentException|Exception */
public function generate(int $count = 10): array; public function generate(int $count = 10): array;
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
public function expire(): void; 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 * @param string $code Code supplied by the client
*
* @return bool true if the code is valid and unused * @return bool true if the code is valid and unused
*
* @throws InvalidArgumentException */ * @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool; public function verifyAndConsume(string $code): bool;
} }
+22 -14
View File
@@ -6,13 +6,14 @@ namespace App\Service;
use App\AppConstants; use App\AppConstants;
use App\MonitorCacheKeys; use App\MonitorCacheKeys;
use App\Trait\GetTotpTrait;
use App\Trait\HasLoggerTrait; use App\Trait\HasLoggerTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
use DateTimeImmutable; use DateTimeImmutable;
use Exception; use Exception;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use App\Trait\GetTotpTrait; use Symfony\Component\DependencyInjection\Attribute\Target;
/** backup-codes are case‑insensitive alphanumeric strings /** backup-codes are case‑insensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */ * they are single-use and marked as used after successful authentication */
@@ -29,27 +30,31 @@ final readonly class BackupCodeManager implements BackupCodeInterface
private CacheItemPoolInterface $sessionCache; private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $sessionCache) public function __construct(
{ #[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
) {
$this->sessionCache = new MonitorCacheKeys($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 * @param int $count Number of codes to generate
*
* @return string[] Generated backup codes * @return string[] Generated backup codes
*
* @throws InvalidArgumentException|Exception */ * @throws InvalidArgumentException|Exception */
public function generate(int $count = self::DEFAULT_COUNT): array public function generate(int $count = self::DEFAULT_COUNT): array
{ {
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH); $length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
$codes = []; $codes = [];
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; ++$i) {
/* output is alphanumeric string of given length */ /* output is alphanumeric string of given length */
$codes[] = strtolower(str_pad(substr(base_convert(bin2hex( $codes[] = strtolower(str_pad(substr(base_convert(bin2hex(
random_bytes($length) random_bytes($length),
), 16, 36), 0, $length), $length, '0', STR_PAD_LEFT)); ), 16, 36), 0, $length), $length, '0', \STR_PAD_LEFT));
} }
$this->saveCodes($codes); $this->saveCodes($codes);
$this->logger->info("generated {$count} backup codes"); $this->logger->info("generated {$count} backup codes");
return $codes; return $codes;
} }
@@ -62,35 +67,38 @@ final readonly class BackupCodeManager implements BackupCodeInterface
$itemsToRemove[] = $key; $itemsToRemove[] = $key;
} }
} }
if (count($itemsToRemove) > 0) { if (\count($itemsToRemove) > 0) {
$this->sessionCache->deleteItems($itemsToRemove); $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 * @param string $code Code supplied by the client
*
* @return bool true if the code is valid and unused * @return bool true if the code is valid and unused
*
* @throws InvalidArgumentException */ * @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool public function verifyAndConsume(string $code): bool
{ {
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */ /* 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)); $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()) { if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code"); $this->logger->debug('valid backup code');
/* mark backup code as spent */ /* mark backup code as spent */
$backupItem->set(false); /* used */ $backupItem->set(false); /* used */
/* per PSR6, if no expiration is set, implementation may set a default, /* 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 */ * we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat( $backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', 'Y-m-d',
AppConstants::FAR_FUTURE_DATE AppConstants::FAR_FUTURE_DATE,
)); ));
$this->sessionCache->save($backupItem); $this->sessionCache->save($backupItem);
return true; return true;
} }
return false; 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 */ * we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat( $backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', 'Y-m-d',
AppConstants::FAR_FUTURE_DATE AppConstants::FAR_FUTURE_DATE,
)); ));
$this->sessionCache->saveDeferred($backupItem); $this->sessionCache->saveDeferred($backupItem);
} }
+5 -5
View File
@@ -6,21 +6,21 @@ namespace App\Service;
interface DomainInterface 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 */ * @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string; 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 * @param string $url Where we are thinking of sending the user
*
* @return bool Returns true if it is acceptable to send the user there */ * @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool; public function validReturn(string $url): bool;
/** check if host-base matches auth-base /** check if host-base matches auth-base.
* @param string $host
* @return bool returns true if and only if host matches base domain of auth */ * @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool; 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 */ * @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string; 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 */ /* top-level-domains which are known to have multiple parts */
private const array TLD = [ private const array TLD = [
'ai' => ['com','net','off','org'], 'ai' => ['com', 'net', 'off', 'org'],
'am' => ['radio'], 'am' => ['radio'],
'at' => ['ac','co','gv','or'], 'at' => ['ac', 'co', 'gv', 'or'],
'au' => ['com','net','org','edu','gov','asn','id'], 'au' => ['com', 'net', 'org', 'edu', 'gov', 'asn', 'id'],
'az' => ['com','net','org'], 'az' => ['com', 'net', 'org'],
'bd' => ['com','net','org','gov','mil','ac'], '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'], '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'], 'by' => ['com', 'net', 'org', 'gov', 'mil', 'of'],
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'], 'ca' => ['ab', 'bc', 'mb', 'nb', 'nf', 'nl', 'ns', 'nt', 'nu', 'on', 'pe', 'qc', 'sk', 'yk'],
'cc' => [], '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'], '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'], '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'], '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'], 'de' => ['com'],
'dk' => ['co'], 'dk' => ['co'],
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'], 'ec' => ['com', 'net', 'org', 'gov', 'mil', 'edu', 'fin', 'med', 'pro'],
'ee' => ['com','org','pri'], 'ee' => ['com', 'org', 'pri'],
'eg' => ['com','net','org','gov','edu','mil'], 'eg' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'es' => ['com','nom','org','edu','gob'], 'es' => ['com', 'nom', 'org', 'edu', 'gob'],
'eu' => [], 'eu' => [],
'fi' => ['aland'], 'fi' => ['aland'],
'fm' => ['radio'], 'fm' => ['radio'],
'fr' => ['com','nom','tm','asso','gouv','pol'], 'fr' => ['com', 'nom', 'tm', 'asso', 'gouv', 'pol'],
'ge' => ['com','net','org','edu','gov','mil'], 'ge' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
'gg' => ['co','net','org'], 'gg' => ['co', 'net', 'org'],
'gr' => ['com','net','org','gov','edu','mil'], 'gr' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'hk' => ['com','net','org','gov','edu','idv'], 'hk' => ['com', 'net', 'org', 'gov', 'edu', 'idv'],
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'], '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'], 'id' => ['ac', 'biz', 'co', 'desa', 'go', 'mil', 'my', 'net', 'or', 'sch', 'web'],
'ie' => ['gov'], 'ie' => ['gov'],
'il' => ['ac','co','gov','idf','k12','muni','net','org'], 'il' => ['ac', 'co', 'gov', 'idf', 'k12', 'muni', 'net', 'org'],
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'], 'in' => ['co', 'firm', 'gen', 'ind', 'net', 'org', 'ac', 'edu', 'res', 'gov', 'mil'],
'iq' => ['com','net','org','gov','edu','mil'], 'iq' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'ir' => ['ac','co','gov','id','net','org','sch'], 'ir' => ['ac', 'co', 'gov', 'id', 'net', 'org', 'sch'],
'is' => ['net','com','org','edu','gov','int'], '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'], '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'], 'je' => ['co', 'net', 'org'],
'jo' => ['com','net','org','gov','edu','mil','sch'], 'jo' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch'],
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'], 'jp' => ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'], 'ke' => ['co', 'ne', 'or', 'ac', 'go', 'me', 'mobi', 'info', 'sc', 'pro'],
'kg' => ['com','net','org','gov','mil','edu'], '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'], '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'], 'kz' => ['com', 'net', 'org', 'edu', 'gov', 'mil'],
'li' => [], 'li' => [],
'lt' => ['gov'], 'lt' => ['gov'],
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'], 'lv' => ['com', 'net', 'org', 'edu', 'gov', 'mil', 'id', 'asn', 'conf'],
'ly' => ['com','net','org','gov','edu','sch','med','id'], 'ly' => ['com', 'net', 'org', 'gov', 'edu', 'sch', 'med', 'id'],
'ma' => ['co','net','org','gov','press','ac'], 'ma' => ['co', 'net', 'org', 'gov', 'press', 'ac'],
'mk' => ['com','net','org','edu','gov','inf','name','pro'], 'mk' => ['com', 'net', 'org', 'edu', 'gov', 'inf', 'name', 'pro'],
'mx' => ['com','net','org','gov','edu','mil'], 'mx' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'my' => ['com','net','org','gov','edu','mil','name'], 'my' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
'na' => ['com','net','org','alt','edu','gov','mil','pro'], 'na' => ['com', 'net', 'org', 'alt', 'edu', 'gov', 'mil', 'pro'],
'net' => ['gb','hu','in','jp','se','uk','cn','nz'], 'net' => ['gb', 'hu', 'in', 'jp', 'se', 'uk', 'cn', 'nz'],
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'], 'ng' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'sch', 'name', 'gov'],
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'], 'ni' => ['ac', 'co', 'com', 'edu', 'gob', 'mil', 'net', 'nom', 'org'],
'nl' => ['bv','co'], 'nl' => ['bv', 'co'],
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'], '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'], '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'], 'om' => ['com', 'net', 'org', 'gov', 'edu', 'med', 'mil', 'sch'],
'org' => ['ae','us','lu'], 'org' => ['ae', 'us', 'lu'],
'pe' => ['com','net','org','gob','edu','mil','nom'], 'pe' => ['com', 'net', 'org', 'gob', 'edu', 'mil', 'nom'],
'ph' => ['com','net','org','gov','edu','mil'], 'ph' => ['com', 'net', 'org', 'gov', 'edu', 'mil'],
'pk' => ['com','net','org','fam','biz','edu','gov','web'], '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'], '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'], 'pr' => ['ac', 'co', 'edu', 'gov', 'info', 'island', 'pro', 'net', 'org'],
'pt' => ['com','net','org','gov','edu','int','publ'], 'pt' => ['com', 'net', 'org', 'gov', 'edu', 'int', 'publ'],
'py' => ['com','net','org','gov','edu','mil','co'], 'py' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'co'],
'qa' => ['com','net','org','gov','edu','mil','sch','name'], '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'], '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'], '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'], 'sa' => ['com', 'net', 'org', 'gov', 'med', 'pub', 'edu', 'sch'],
'sb' => ['com','net','org','edu','gov'], 'sb' => ['com', 'net', 'org', 'edu', 'gov'],
'sc' => ['com','net','org','gov','edu'], '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'], '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'], 'sg' => ['com', 'net', 'org', 'gov', 'edu', 'per'],
'sh' => ['com','net','org','gov','mil','edu'], 'sh' => ['com', 'net', 'org', 'gov', 'mil', 'edu'],
'sk' => ['co','com','edu','gov','mil','net','org','nfo'], 'sk' => ['co', 'com', 'edu', 'gov', 'mil', 'net', 'org', 'nfo'],
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'], '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'], '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'], 'sv' => ['com', 'edu', 'gob', 'org', 'red'],
'sy' => ['com','net','org','gov','edu','mil','name'], 'sy' => ['com', 'net', 'org', 'gov', 'edu', 'mil', 'name'],
'th' => ['ac','co','go','in','mi','net','or'], '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'], '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'], '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'], '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'], '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'], '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'], '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'], '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'], 'uy' => ['com', 'net', 'org', 'gub', 'mil', 'edu'],
've' => ['co','com','edu','gob','info','net','org','web'], 've' => ['co', 'com', 'edu', 'gob', 'info', 'net', 'org', 'web'],
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'], 'vn' => ['com', 'net', 'org', 'edu', 'gov', 'int', 'ac', 'biz', 'info', 'name', 'pro', 'health'],
'yu' => ['ac','co','edu','gov','org'], 'yu' => ['ac', 'co', 'edu', 'gov', 'org'],
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'], 'za' => ['ac', 'alt', 'co', 'edu', 'gov', 'law', 'mil', 'net', 'ngo', 'nom', 'org', 'school', 'tm', 'web'],
]; ];
private bool $subdomainRedirect; private bool $subdomainRedirect;
@@ -111,38 +111,41 @@ final readonly class DomainManager implements DomainInterface
public function __construct( public function __construct(
#[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect, #[Autowire('%app.subdomain_redirect%')] bool $subdomainRedirect,
#[Autowire('%app.auth_subdomain%')] string $authSubdomain, #[Autowire('%app.auth_subdomain%')] string $authSubdomain,
) { ) {
$this->subdomainRedirect = $subdomainRedirect; $this->subdomainRedirect = $subdomainRedirect;
$this->authSubdomain = $authSubdomain; $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 */ * @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string public function getAuthSubdomain(): ?string
{ {
if ($this->authBase()) { if ($this->authBase()) {
return $this->authSubdomain; return $this->authSubdomain;
} }
return null; 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 * @param string $url Where we are thinking of sending the user
*
* @return bool Returns true if it is acceptable to send the user there */ * @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool public function validReturn(string $url): bool
{ {
/* ensure url is valid and, when using an auth subdomain, /* ensure url is valid and, when using an auth subdomain,
* that the url host matches the base domain */ * that the url host matches the base domain */
if (!filter_var($url, FILTER_VALIDATE_URL)) { if (!filter_var($url, \FILTER_VALIDATE_URL)) {
return false; return false;
} }
if ($this->authBase()) { if ($this->authBase()) {
$host = parse_url($url, PHP_URL_HOST); $host = parse_url($url, \PHP_URL_HOST);
if ($host === null || $host === false || $host === '') { if (null === $host || false === $host || '' === $host) {
return false; return false;
} }
/* do not send the user to another domain */ /* do not send the user to another domain */
return $this->matchesAuth($host); return $this->matchesAuth($host);
} }
@@ -150,58 +153,64 @@ final readonly class DomainManager implements DomainInterface
return true; return true;
} }
/** check if host-base matches auth-base /** check if host-base matches auth-base.
* @param string $host
* @return bool returns true if and only if host matches base domain of auth */ * @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool public function matchesAuth(string $host): bool
{ {
$hostBase = $this->baseDomain($host); $hostBase = $this->baseDomain($host);
$authBase = $this->baseDomain($this->authSubdomain); $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 */ * @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string public function authBase(): ?string
{ {
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) { if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
return $this->baseDomain($this->authSubdomain); return $this->baseDomain($this->authSubdomain);
} }
return null; return null;
} }
/** this lets us determine the base domain of the given ip, localhost, or domain /** 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" * "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 * @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 */ * @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
private function baseDomain(string $host): ?string private function baseDomain(string $host): ?string
{ {
/* if host is an ip address (or localhost), leave it as is */ /* 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; return null;
} }
$parts = explode('.', strtolower($host)); $parts = explode('.', strtolower($host));
$keep = $this->baseLength($parts); $keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep); $parts = \array_slice($parts, -$keep);
return implode('.', $parts); 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 * @param string[] $parts pieces of a domain split by "." dot
*
* @return int typically 2 but sometimes 3 */ * @return int typically 2 but sometimes 3 */
private function baseLength(array $parts): int private function baseLength(array $parts): int
{ {
$length = count($parts); $length = \count($parts);
$baseLength = min(2, $length); $baseLength = min(2, $length);
/* check if host should retain 3 parts, due to TLD */ /* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) && if (\count($parts) > 2 && isset(self::TLD[$parts[$length - 1]])
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true) && \in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
) { ) {
$baseLength = min(3, $length); $baseLength = min(3, $length);
} }
return $baseLength; return $baseLength;
} }
} }
+20 -17
View File
@@ -13,6 +13,7 @@ use App\Trait\MakeNonceTrait;
use App\Trait\StringTrait; use App\Trait\StringTrait;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -30,9 +31,9 @@ final readonly class LoginManager implements LoginInterface
/** @throws InvalidArgumentException */ /** @throws InvalidArgumentException */
public function __construct( public function __construct(
CacheItemPoolInterface $sessionCache, #[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager, private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager, private DomainInterface $domainManager,
) { ) {
$this->sessionCache = new MonitorCacheKeys($sessionCache); $this->sessionCache = new MonitorCacheKeys($sessionCache);
} }
@@ -41,13 +42,13 @@ final readonly class LoginManager implements LoginInterface
public function checkToken(Payload $payload, Request $request): ?Response public function checkToken(Payload $payload, Request $request): ?Response
{ {
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */ /* 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 */ /* requested to grant ip access, but that is not enabled */
$payload->scope = Scope::Cookie; $payload->scope = Scope::Cookie;
} }
if ($this->getTotp()->verify($payload->token, null, 1) || if ($this->getTotp()->verify($payload->token, null, 1)
$this->backupCodeManager->verifyAndConsume($payload->token) || $this->backupCodeManager->verifyAndConsume($payload->token)
) { ) {
/* token is correct (TOTP or Backup) */ /* token is correct (TOTP or Backup) */
@@ -56,7 +57,7 @@ final readonly class LoginManager implements LoginInterface
if ($nonceItem->isHit() && $nonceItem->get()) { if ($nonceItem->isHit() && $nonceItem->get()) {
/* mark nonce as spent */ /* mark nonce as spent */
$nonceItem->set(false); /* invalid */ $nonceItem->set(false); /* invalid */
$nonceItem->expiresAfter(LoginManager::NONCE_TTL); /* keep briefly */ $nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
$this->nonceCache->save($nonceItem); $this->nonceCache->save($nonceItem);
/* token authentication successful, grant access and set response */ /* token authentication successful, grant access and set response */
@@ -65,27 +66,27 @@ final readonly class LoginManager implements LoginInterface
/* if they just want this one page, return ok, to grant them access */ /* if they just want this one page, return ok, to grant them access */
$response = $this->authSuccessResponse($cleanId, $this->config); $response = $this->authSuccessResponse($cleanId, $this->config);
if ($payload->scope !== Scope::None) { if (Scope::None !== $payload->scope) {
/* grant access based on the requested scope */ /* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) { if (Scope::Cookie === $payload->scope) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost())); $response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} elseif ($payload->scope === Scope::Ip) { } elseif (Scope::Ip === $payload->scope) {
$this->setIp($cleanId, $request->getClientIp()); $this->setIp($cleanId, $request->getClientIp());
} }
if ($payload->json) { if ($payload->json) {
$contentType = 'application/json'; $contentType = 'application/json';
$content = json_encode([ $content = json_encode([
'message' => 'Login successful', 'message' => 'Login successful',
'nonce' => null, 'nonce' => null,
]); ]);
} else { } else {
$contentType = 'text/html'; $contentType = 'text/html';
$content = "hi $cleanId, please reload"; $content = "hi $cleanId, please reload";
} }
$location = $request->query->has('return') && $location = $request->query->has('return')
$this->domainManager->validReturn($request->query->get('return')) ? && $this->domainManager->validReturn($request->query->get('return')) ?
"{$request->query->get('return')}" : "{$request->query->get('return')}" :
"{$request->getPathInfo()}{$request->getQueryString()}"; "{$request->getPathInfo()}{$request->getQueryString()}";
@@ -97,9 +98,11 @@ final readonly class LoginManager implements LoginInterface
} }
$this->logger->debug("successful login for: $cleanId"); $this->logger->debug("successful login for: $cleanId");
return $response; return $response;
} }
} }
return null; return null;
} }
@@ -109,11 +112,11 @@ final readonly class LoginManager implements LoginInterface
/* successful auth with token, store session and set the cookie */ /* successful auth with token, store session and set the cookie */
$ulid = new Ulid(); $ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem( $sessionCookie = $this->sessionCache->getItem(
$this->makeCacheKey("cookie_$ulid") $this->makeCacheKey("cookie_$ulid"),
); );
if ($sessionCookie->isHit()) { if ($sessionCookie->isHit()) {
/* it is supposed to be impossible to have collisions */ /* it is supposed to be impossible to have collisions */
$this->logger->error("aborting: ULID collision"); $this->logger->error('aborting: ULID collision');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error'); throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
} }
$sessionCookie->set($id); $sessionCookie->set($id);
+15 -15
View File
@@ -32,23 +32,23 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
public function isEmpty(): bool public function isEmpty(): bool
{ {
return $this->patterns === []; return [] === $this->patterns;
} }
public function matches(string $host, string $path): bool public function matches(string $host, string $path): bool
{ {
if ($this->patterns === []) { if ([] === $this->patterns) {
return false; return false;
} }
$host = strtolower($host); $host = strtolower($host);
foreach ($this->patterns as $entry) { foreach ($this->patterns as $entry) {
if ($entry['host'] !== null && $entry['host'] !== $host) { if (null !== $entry['host'] && $entry['host'] !== $host) {
continue; continue;
} }
if (preg_match($entry['regex'], $path) === 1) { if (1 === preg_match($entry['regex'], $path)) {
return true; return true;
} }
} }
@@ -63,7 +63,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
*/ */
private function parse(string $publicPaths): array private function parse(string $publicPaths): array
{ {
if (trim($publicPaths) === '') { if ('' === trim($publicPaths)) {
return []; return [];
} }
@@ -71,7 +71,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
foreach (explode(',', $publicPaths) as $raw) { foreach (explode(',', $publicPaths) as $raw) {
$entry = trim($raw); $entry = trim($raw);
if ($entry === '') { if ('' === $entry) {
continue; continue;
} }
@@ -90,7 +90,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
} }
$patterns[] = [ $patterns[] = [
'host' => $host, 'host' => $host,
'regex' => $this->compilePattern($path), 'regex' => $this->compilePattern($path),
]; ];
} }
@@ -109,33 +109,33 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
private function compilePattern(string $pattern): string private function compilePattern(string $pattern): string
{ {
$regex = ''; $regex = '';
$length = strlen($pattern); $length = \strlen($pattern);
$i = 0; $i = 0;
while ($i < $length) { while ($i < $length) {
// Check for ** (must be at current position) // Check for ** (must be at current position)
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') { if ($i + 1 < $length && '*' === $pattern[$i] && '*' === $pattern[$i + 1]) {
$i += 2; $i += 2;
if ($i >= $length) { if ($i >= $length) {
// ** at end of pattern: zero or more chars including / // ** at end of pattern: zero or more chars including /
$regex .= '.*'; $regex .= '.*';
} elseif ($pattern[$i] === '/') { } elseif ('/' === $pattern[$i]) {
// /**/ in middle: zero or more intermediate segments // /**/ in middle: zero or more intermediate segments
$regex .= '(?:.*/)?'; $regex .= '(?:.*/)?';
$i += 1; // skip the / after ** ++$i; // skip the / after **
} else { } else {
// ** not followed by / or end, treat as .* // ** not followed by / or end, treat as .*
$regex .= '.*'; $regex .= '.*';
} }
} elseif ($pattern[$i] === '*') { } elseif ('*' === $pattern[$i]) {
$regex .= '[^/]+'; $regex .= '[^/]+';
$i += 1; ++$i;
} else { } else {
$regex .= preg_quote($pattern[$i], '#'); $regex .= preg_quote($pattern[$i], '#');
$i += 1; ++$i;
} }
} }
return '#^' . $regex . '$#'; return '#^'.$regex.'$#';
} }
} }
+1 -1
View File
@@ -25,7 +25,7 @@ trait GetTotpTrait
{ {
$otp = Factory::loadFromProvisioningUri( $otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(), $this->config->totpUri(),
$this->config->clock() $this->config->clock(),
); );
if ($otp instanceof TOTPInterface) { if ($otp instanceof TOTPInterface) {
return $otp; return $otp;
+9 -8
View File
@@ -7,6 +7,7 @@ namespace App\Trait;
use Exception; use Exception;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required; use Symfony\Contracts\Service\Attribute\Required;
@@ -23,8 +24,9 @@ trait MakeNonceTrait
protected readonly CacheItemPoolInterface $nonceCache; protected readonly CacheItemPoolInterface $nonceCache;
#[Required] #[Required]
public function setNonceCache(CacheItemPoolInterface $nonceCache): void public function setNonceCache(
{ #[Target('nonceCache')] CacheItemPoolInterface $nonceCache,
): void {
$this->nonceCache = $nonceCache; $this->nonceCache = $nonceCache;
} }
@@ -33,18 +35,16 @@ trait MakeNonceTrait
{ {
/* convert raw binary into base64url */ /* convert raw binary into base64url */
$nonce = rtrim(strtr(base64_encode(random_bytes( $nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH static::NONCE_LENGTH,
)), '+/', '-_'), '='); )), '+/', '-_'), '=');
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce)); $nonceItem = $this->nonceCache->getItem($this->makeCacheKey($nonce));
if ($nonceItem->isHit()) { if ($nonceItem->isHit()) {
if ($retries < 1) { if ($retries < 1) {
$this->logger->error("aborting: multiple nonce collisions"); $this->logger->error('aborting: multiple nonce collisions');
throw new HttpException( throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
Response::HTTP_INTERNAL_SERVER_ERROR,
'Internal Server Error'
);
} }
/* managed to have a collision, try again */ /* managed to have a collision, try again */
return $this->makeNonce($retries - 1); return $this->makeNonce($retries - 1);
} }
@@ -53,6 +53,7 @@ trait MakeNonceTrait
$nonceItem->expiresAfter(static::NONCE_TTL); $nonceItem->expiresAfter(static::NONCE_TTL);
$this->logger->debug("added nonce: $nonce"); $this->logger->debug("added nonce: $nonce");
$this->nonceCache->save($nonceItem); $this->nonceCache->save($nonceItem);
return $nonce; return $nonce;
} }
} }
+4 -4
View File
@@ -30,7 +30,7 @@ trait StringTrait
$headers = ['Content-Type' => 'text/plain']; $headers = ['Content-Type' => 'text/plain'];
$headerValue = $this->resolveRemoteUser($id, $config); $headerValue = $this->resolveRemoteUser($id, $config);
if ($headerValue !== null) { if (null !== $headerValue) {
$headers['Remote-User'] = $headerValue; $headers['Remote-User'] = $headerValue;
} }
@@ -45,9 +45,9 @@ trait StringTrait
{ {
return match ($config->remoteUserMode()) { return match ($config->remoteUserMode()) {
RemoteUserMode::Session => $id, RemoteUserMode::Session => $id,
RemoteUserMode::Static => $config->remoteUserStatic(), RemoteUserMode::Static => $config->remoteUserStatic(),
RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id, RemoteUserMode::Mapped => $config->remoteUserMap()[$id] ?? $id,
RemoteUserMode::None => null, RemoteUserMode::None => null,
}; };
} }
} }
+5 -3
View File
@@ -15,7 +15,7 @@ use Psr\Clock\ClockInterface;
final readonly class Utilities final readonly class Utilities
{ {
public function __construct( public function __construct(
private ClockInterface $clock, private ClockInterface $clock,
private CacheItemPoolInterface $appPool, private CacheItemPoolInterface $appPool,
) { ) {
} }
@@ -31,6 +31,7 @@ final readonly class Utilities
} }
$this->showTotp($totp); $this->showTotp($totp);
return $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 */ * we want this to keep forever, so a few hundred years should do it */
$totpItem->expiresAt(DateTimeImmutable::createFromFormat( $totpItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', 'Y-m-d',
AppConstants::FAR_FUTURE_DATE AppConstants::FAR_FUTURE_DATE,
)); ));
$this->appPool->save($totpItem); $this->appPool->save($totpItem);
return $totp; return $totp;
} }
@@ -64,7 +66,7 @@ final readonly class Utilities
loading TOTP, because the env is not set, please copy above into TOTP_URI loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW, RAW,
FILE_APPEND \FILE_APPEND,
); );
} }
} }
+3
View File
@@ -11,6 +11,9 @@
".php-cs-fixer.dist.php" ".php-cs-fixer.dist.php"
] ]
}, },
"phpstan/phpstan": {
"version": "2.2.15"
},
"phpunit/phpunit": { "phpunit/phpunit": {
"version": "13.2", "version": "13.2",
"recipe": { "recipe": {
+4 -1
View File
@@ -19,6 +19,8 @@ form.addEventListener('submit', (event) => {
fetch(window.location.href, { fetch(window.location.href, {
method: 'GET', method: 'GET',
headers: { 'X-Preauth': data }, headers: { 'X-Preauth': data },
// never serve this request from, or store it in, the HTTP cache
cache: 'no-store',
}).then((response) => { }).then((response) => {
{% if env.debug > 2 -%} {% if env.debug > 2 -%}
console.log(response); console.log(response);
@@ -28,7 +30,8 @@ form.addEventListener('submit', (event) => {
{% if env.debug > 2 -%} {% if env.debug > 2 -%}
console.log('got redirect response'); console.log('got redirect response');
{% endif -%} {% 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) { } else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) {
{# got json, update the page #} {# got json, update the page #}
{% if env.debug > 2 -%} {% if env.debug > 2 -%}
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{ env.title }}</title> <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') -}} {{- include('_style.html.twig') -}}
</head> </head>
<body id="preauth-body"> <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. */ /** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
private function encodePayload(array $data): string 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), '+/', '-_'), '='); return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
} }
@@ -53,16 +54,16 @@ final class AuthenticationFlowTest extends WebTestCase
bool $json = true, bool $json = true,
): string { ): string {
return $this->encodePayload([ return $this->encodePayload([
'id' => $id, 'id' => $id,
'token' => $token ?? $this->validTotpCode(), 'token' => $token ?? $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => $json, 'json' => $json,
]); ]);
} }
/* ── unauthenticated access ──────────────────────────────────────── */ /* ── unauthenticated access ──────────────────────────────────────── */
public function testUnauthenticatedRequestShowsLoginPage(): void public function test_unauthenticated_request_shows_login_page(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/'); $client->request('GET', '/');
@@ -75,7 +76,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSelectorExists('input[name="totp"]'); self::assertSelectorExists('input[name="totp"]');
} }
public function testLoginPageContainsGeneratedNonce(): void public function test_login_page_contains_generated_nonce(): void
{ {
$client = static::createClient(); $client = static::createClient();
$crawler = $client->request('GET', '/'); $crawler = $client->request('GET', '/');
@@ -86,7 +87,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput); 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(); $client = static::createClient();
$crawler = $client->request('GET', '/'); $crawler = $client->request('GET', '/');
@@ -99,7 +100,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── successful TOTP login ────────────────────────────────────────── */ /* ── successful TOTP login ────────────────────────────────────────── */
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void public function test_successful_totp_login_via_header_sets_cookie_and_redirects(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -111,10 +112,10 @@ final class AuthenticationFlowTest extends WebTestCase
// now submit a valid TOTP via the X-Preauth header // now submit a valid TOTP via the X-Preauth header
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, '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'); 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(); $client = static::createClient();
@@ -141,10 +142,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'bob', 'id' => 'bob',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
@@ -155,7 +156,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('Login successful', $body['message']); self::assertSame('Login successful', $body['message']);
} }
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void public function test_successful_login_returns_html_when_json_false(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -164,10 +165,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'carol', 'id' => 'carol',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => false, 'json' => false,
]), ]),
]); ]);
@@ -176,7 +177,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type')); 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(); $client = static::createClient();
@@ -186,10 +187,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'dave', 'id' => 'dave',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
@@ -214,7 +215,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('dave', $response->headers->get('Remote-User')); 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(); $client = static::createClient();
@@ -223,7 +224,7 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'eve', 'id' => 'eve',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'scope' => 'none', 'scope' => 'none',
@@ -240,7 +241,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── failed login ─────────────────────────────────────────────────── */ /* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void public function test_failed_login_returns_unauthorized_json_with_error(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -249,10 +250,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => '000000', // wrong code 'token' => '000000', // wrong code
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
@@ -266,7 +267,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertNotEmpty($body['nonce']); self::assertNotEmpty($body['nonce']);
} }
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void public function test_failed_login_returns_html_when_json_false(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -275,10 +276,10 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => 'wrong-code', 'token' => 'wrong-code',
'nonce' => $nonce, 'nonce' => $nonce,
'json' => false, 'json' => false,
]), ]),
]); ]);
@@ -288,7 +289,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSelectorExists('form#preauth-form'); self::assertSelectorExists('form#preauth-form');
} }
public function testFailedLoginWithSpentNonceIsRejected(): void public function test_failed_login_with_spent_nonce_is_rejected(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -298,10 +299,10 @@ final class AuthenticationFlowTest extends WebTestCase
// first: successful login consumes the nonce // first: successful login consumes the nonce
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
self::assertSame(303, $client->getResponse()->getStatusCode()); 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 // reuse the same nonce — should fail even with a valid token
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
self::assertSame(401, $client->getResponse()->getStatusCode()); self::assertSame(401, $client->getResponse()->getStatusCode());
} }
public function testFailedLoginWithInvalidNonceIsRejected(): void public function test_failed_login_with_invalid_nonce_is_rejected(): void
{ {
$client = static::createClient(); $client = static::createClient();
// skip fetching a real nonce; use one that was never stored // skip fetching a real nonce; use one that was never stored
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => 'never-issued-nonce', 'nonce' => 'never-issued-nonce',
'json' => true, 'json' => true,
]), ]),
]); ]);
@@ -342,7 +343,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── invalid payload ──────────────────────────────────────────────── */ /* ── invalid payload ──────────────────────────────────────────────── */
public function testInvalidHeaderPayloadReturnsUnauthorized(): void public function test_invalid_header_payload_returns_unauthorized(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -354,7 +355,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame(401, $client->getResponse()->getStatusCode()); self::assertSame(401, $client->getResponse()->getStatusCode());
} }
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void public function test_payload_with_missing_fields_returns_unauthorized(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -370,7 +371,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── invalid cookie ───────────────────────────────────────────────── */ /* ── invalid cookie ───────────────────────────────────────────────── */
public function testInvalidCookieIsClearedAndLoginPageShown(): void public function test_invalid_cookie_is_cleared_and_login_page_shown(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -388,7 +389,7 @@ final class AuthenticationFlowTest extends WebTestCase
true, true,
false, false,
'Strict', 'Strict',
) ),
); );
$client->request('GET', 'https://localhost/'); $client->request('GET', 'https://localhost/');
@@ -399,7 +400,7 @@ final class AuthenticationFlowTest extends WebTestCase
// the stale cookie should be cleared // the stale cookie should be cleared
$cleared = false; $cleared = false;
foreach ($response->headers->getCookies() as $cookie) { foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) { if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true; $cleared = true;
} }
} }
@@ -408,7 +409,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── backup code authentication ───────────────────────────────────── */ /* ── backup code authentication ───────────────────────────────────── */
public function testBackupCodeAuthenticationWorks(): void public function test_backup_code_authentication_works(): void
{ {
$client = static::createClient(); $client = static::createClient();
$container = $client->getContainer(); $container = $client->getContainer();
@@ -423,17 +424,17 @@ final class AuthenticationFlowTest extends WebTestCase
$client->request('GET', '/', [], [], [ $client->request('GET', '/', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'frank', 'id' => 'frank',
'token' => $codes[0], 'token' => $codes[0],
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
self::assertSame(303, $client->getResponse()->getStatusCode()); self::assertSame(303, $client->getResponse()->getStatusCode());
} }
public function testConsumedBackupCodeCannotBeReused(): void public function test_consumed_backup_code_cannot_be_reused(): void
{ {
$client = static::createClient(); $client = static::createClient();
$container = $client->getContainer(); $container = $client->getContainer();
@@ -469,7 +470,7 @@ final class AuthenticationFlowTest extends WebTestCase
/* ── return URL handling ──────────────────────────────────────────── */ /* ── return URL handling ──────────────────────────────────────────── */
public function testSuccessfulLoginWithValidReturnUrl(): void public function test_successful_login_with_valid_return_url(): void
{ {
$client = static::createClient(); $client = static::createClient();
@@ -488,7 +489,7 @@ final class AuthenticationFlowTest extends WebTestCase
self::assertSame('https://example.com/app', $response->headers->get('Location')); 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(); $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);
}
}
+20 -18
View File
@@ -27,6 +27,7 @@ final class PublicAccessFlowTest extends WebTestCase
{ {
$client = parent::createClient($options, $server); $client = parent::createClient($options, $server);
$client->disableReboot(); $client->disableReboot();
return $client; return $client;
} }
@@ -37,13 +38,14 @@ final class PublicAccessFlowTest extends WebTestCase
private function encodePayload(array $data): string 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), '+/', '-_'), '='); return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
} }
/* ── public path accessible without auth ───────────────────────────── */ /* ── public path accessible without auth ───────────────────────────── */
public function testPublicPathAccessibleWithoutAuthentication(): void public function test_public_path_accessible_without_authentication(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/public/some-repo'); $client->request('GET', '/public/some-repo');
@@ -54,7 +56,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertFalse($response->headers->has('Remote-User')); self::assertFalse($response->headers->has('Remote-User'));
} }
public function testPublicPathWithQuerystringAccessible(): void public function test_public_path_with_querystring_accessible(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/public/repo?tab=issues&page=2'); $client->request('GET', '/public/repo?tab=issues&page=2');
@@ -62,7 +64,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertSame(200, $client->getResponse()->getStatusCode()); self::assertSame(200, $client->getResponse()->getStatusCode());
} }
public function testDeepPublicPathAccessible(): void public function test_deep_public_path_accessible(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/public/org/repo/issues/42'); $client->request('GET', '/public/org/repo/issues/42');
@@ -72,7 +74,7 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── non-public path requires auth ─────────────────────────────────── */ /* ── non-public path requires auth ─────────────────────────────────── */
public function testNonPublicPathShowsLoginPage(): void public function test_non_public_path_shows_login_page(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/private/settings'); $client->request('GET', '/private/settings');
@@ -81,7 +83,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertSelectorExists('form#preauth-form'); self::assertSelectorExists('form#preauth-form');
} }
public function testRootPathShowsLoginPage(): void public function test_root_path_shows_login_page(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/'); $client->request('GET', '/');
@@ -89,7 +91,7 @@ final class PublicAccessFlowTest extends WebTestCase
self::assertSame(401, $client->getResponse()->getStatusCode()); self::assertSame(401, $client->getResponse()->getStatusCode());
} }
public function testExactPublicPathWithoutSlashNotMatched(): void public function test_exact_public_path_without_slash_not_matched(): void
{ {
// /public/** does NOT match /public (no trailing content) // /public/** does NOT match /public (no trailing content)
$client = static::createClient(); $client = static::createClient();
@@ -100,17 +102,17 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── rate limiting ─────────────────────────────────────────────────── */ /* ── rate limiting ─────────────────────────────────────────────────── */
public function testRateLimitEnforcedAfterBurstExceeded(): void public function test_rate_limit_enforced_after_burst_exceeded(): void
{ {
$client = static::createClient(); $client = static::createClient();
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed // PUBLIC_BURST_COUNT=3 — first 3 requests succeed
for ($i = 0; $i < 3; $i++) { for ($i = 0; $i < 3; ++$i) {
$client->request('GET', '/public/repo'); $client->request('GET', '/public/repo');
self::assertSame( self::assertSame(
200, 200,
$client->getResponse()->getStatusCode(), $client->getResponse()->getStatusCode(),
"Request $i should have been allowed" "Request $i should have been allowed",
); );
} }
@@ -125,12 +127,12 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── authenticated user bypasses public rate limiter ───────────────── */ /* ── authenticated user bypasses public rate limiter ───────────────── */
public function testAuthenticatedUserBypassesPublicRateLimit(): void public function test_authenticated_user_bypasses_public_rate_limit(): void
{ {
$client = static::createClient(); $client = static::createClient();
// First, exhaust the public rate limiter // First, exhaust the public rate limiter
for ($i = 0; $i < 4; $i++) { for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo'); $client->request('GET', '/public/repo');
} }
// Confirm rate limit is in effect // Confirm rate limit is in effect
@@ -145,10 +147,10 @@ final class PublicAccessFlowTest extends WebTestCase
$client->request('GET', '/private', [], [], [ $client->request('GET', '/private', [], [], [
'HTTP_X-Preauth' => $this->encodePayload([ 'HTTP_X-Preauth' => $this->encodePayload([
'id' => 'alice', 'id' => 'alice',
'token' => $this->validTotpCode(), 'token' => $this->validTotpCode(),
'nonce' => $nonce, 'nonce' => $nonce,
'json' => true, 'json' => true,
]), ]),
]); ]);
self::assertSame(303, $client->getResponse()->getStatusCode()); self::assertSame(303, $client->getResponse()->getStatusCode());
@@ -166,7 +168,7 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── 200 response has correct content type ─────────────────────────── */ /* ── 200 response has correct content type ─────────────────────────── */
public function testPublicAccessResponseIsPlainText(): void public function test_public_access_response_is_plain_text(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/public/repo'); $client->request('GET', '/public/repo');
@@ -178,12 +180,12 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── 429 response renders error template ───────────────────────────── */ /* ── 429 response renders error template ───────────────────────────── */
public function testRateLimitedResponseRendersErrorTemplate(): void public function test_rate_limited_response_renders_error_template(): void
{ {
$client = static::createClient(); $client = static::createClient();
// Exhaust rate limit // Exhaust rate limit
for ($i = 0; $i < 4; $i++) { for ($i = 0; $i < 4; ++$i) {
$client->request('GET', '/public/repo'); $client->request('GET', '/public/repo');
} }
@@ -198,7 +200,7 @@ final class PublicAccessFlowTest extends WebTestCase
/* ── security headers still applied to public responses ────────────── */ /* ── security headers still applied to public responses ────────────── */
public function testSecurityHeadersOnPublicAccess(): void public function test_security_headers_on_public_access(): void
{ {
$client = static::createClient(); $client = static::createClient();
$client->request('GET', '/public/repo'); $client->request('GET', '/public/repo');
+37 -26
View File
@@ -4,13 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Support; namespace App\Tests\Support;
use App\ConfigBag; use DateTimeImmutable;
use App\Service\DomainManager; use Symfony\Component\RateLimiter\LimiterInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\RateLimiter\RateLimit; use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use Twig\Environment; use Twig\Environment;
use Twig\Loader\FilesystemLoader; use Twig\Loader\FilesystemLoader;
@@ -25,26 +22,27 @@ trait ListenerTestHelper
/** Build a Twig Environment pointed at the project's real templates. */ /** Build a Twig Environment pointed at the project's real templates. */
private function makeTwig(): Environment 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]); $twig = new Environment($loader, ['strict_variables' => true]);
// the templates reference a global `env` object; supply one with the // the templates reference a global `env` object; supply one with the
// keys used by base/login/error/_script/_style // keys used by base/login/error/_script/_style
$twig->addGlobal('env', (object)[ $twig->addGlobal('env', (object) [
'title' => 'Pre-Authentication System', 'title' => 'Pre-Authentication System',
'bg_color' => '#029386', 'bg_color' => '#029386',
'fg_color' => '#ffffff', 'fg_color' => '#ffffff',
'error_color' => '#ffb16d', 'error_color' => '#ffb16d',
'id_name' => 'Session ID', 'id_name' => 'Session ID',
'token_name' => 'Authentication Token', 'token_name' => 'Authentication Token',
'submit_name' => 'Submit', 'submit_name' => 'Submit',
'error_message' => 'Unsuccessful login attempt', 'error_message' => 'Unsuccessful login attempt',
'teapot' => true, 'teapot' => true,
'teapot_title' => "I'm a teapot", 'teapot_title' => "I'm a teapot",
'teapot_message' => 'I refuse to brew coffee', 'teapot_message' => 'I refuse to brew coffee',
'too_many_title' => 'Too many requests', 'too_many_title' => 'Too many requests',
'too_many_message' => 'Try again later', 'too_many_message' => 'Try again later',
'debug' => 0, 'debug' => 0,
]); ]);
return $twig; return $twig;
} }
@@ -55,10 +53,12 @@ trait ListenerTestHelper
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface
{ {
$limiter = $this->makeLimiter($remainingTokens); $limiter = $this->makeLimiter($remainingTokens);
return new class ($limiter) implements RateLimiterFactoryInterface {
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter) public function __construct(private LimiterInterface $limiter)
{ {
} }
public function create(?string $key = null): LimiterInterface public function create(?string $key = null): LimiterInterface
{ {
return $this->limiter; return $this->limiter;
@@ -70,22 +70,26 @@ trait ListenerTestHelper
{ {
$rateLimit = new RateLimit( $rateLimit = new RateLimit(
$remainingTokens, $remainingTokens,
new \DateTimeImmutable('+10 seconds'), new DateTimeImmutable('+10 seconds'),
$remainingTokens > 0, $remainingTokens > 0,
10, 10,
); );
return new class ($rateLimit) implements LimiterInterface {
return new class($rateLimit) implements LimiterInterface {
public function __construct(private RateLimit $rateLimit) public function __construct(private RateLimit $rateLimit)
{ {
} }
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{ {
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
} }
public function consume(int $tokens = 1): RateLimit public function consume(int $tokens = 1): RateLimit
{ {
return $this->rateLimit; return $this->rateLimit;
} }
public function reset(): void public function reset(): void
{ {
} }
@@ -98,35 +102,42 @@ trait ListenerTestHelper
*/ */
private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface
{ {
$limiter = new class ($threshold) implements LimiterInterface { $limiter = new class($threshold) implements LimiterInterface {
private int $consumed = 0; private int $consumed = 0;
public function __construct(private int $threshold) public function __construct(private int $threshold)
{ {
} }
public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation
{ {
throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException();
} }
public function consume(int $tokens = 1): RateLimit public function consume(int $tokens = 1): RateLimit
{ {
$this->consumed += $tokens; $this->consumed += $tokens;
$remaining = max(0, $this->threshold - $this->consumed); $remaining = max(0, $this->threshold - $this->consumed);
return new RateLimit( return new RateLimit(
$remaining, $remaining,
new \DateTimeImmutable('+10 seconds'), new DateTimeImmutable('+10 seconds'),
$remaining > 0, $remaining > 0,
$this->threshold, $this->threshold,
); );
} }
public function reset(): void public function reset(): void
{ {
$this->consumed = 0; $this->consumed = 0;
} }
}; };
return new class ($limiter) implements RateLimiterFactoryInterface {
return new class($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter) public function __construct(private LimiterInterface $limiter)
{ {
} }
public function create(?string $key = null): LimiterInterface public function create(?string $key = null): LimiterInterface
{ {
return $this->limiter; return $this->limiter;
+6 -3
View File
@@ -5,11 +5,9 @@ declare(strict_types=1);
namespace App\Tests\Support; namespace App\Tests\Support;
use App\ConfigBag; use App\ConfigBag;
use App\Enum\RemoteUserMode;
use App\Utilities; use App\Utilities;
use DateTimeImmutable; use DateTimeImmutable;
use OTPHP\TOTP; use OTPHP\TOTP;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface as PsrClockInterface; use Psr\Clock\ClockInterface as PsrClockInterface;
@@ -31,10 +29,12 @@ trait TotpTestHelper
private function frozenClock(): PsrClockInterface private function frozenClock(): PsrClockInterface
{ {
$time = self::FROZEN_TIME; $time = self::FROZEN_TIME;
return new class ($time) implements PsrClockInterface {
return new class($time) implements PsrClockInterface {
public function __construct(private string $time) public function __construct(private string $time)
{ {
} }
public function now(): DateTimeImmutable public function now(): DateTimeImmutable
{ {
return new DateTimeImmutable($this->time); return new DateTimeImmutable($this->time);
@@ -47,6 +47,7 @@ trait TotpTestHelper
{ {
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock()); $totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
$totp->setLabel('Test-TOTP'); $totp->setLabel('Test-TOTP');
return $totp->getProvisioningUri(); return $totp->getProvisioningUri();
} }
@@ -79,6 +80,7 @@ trait TotpTestHelper
): ConfigBag { ): ConfigBag {
$clock = $this->frozenClock(); $clock = $this->frozenClock();
$utilities = $this->createUtilities($clock); $utilities = $this->createUtilities($clock);
return new ConfigBag( return new ConfigBag(
$utilities, $utilities,
$clock, $clock,
@@ -107,6 +109,7 @@ trait TotpTestHelper
$item = $this->createStub(CacheItemInterface::class); $item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(false); $item->method('isHit')->willReturn(false);
$cache->method('getItem')->willReturn($item); $cache->method('getItem')->willReturn($item);
return new Utilities($clock, $cache); return new Utilities($clock, $cache);
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@ class TestKernel extends AppKernel
{ {
parent::build($container); parent::build($container);
$container->addCompilerPass(new class () implements CompilerPassInterface { $container->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void public function process(ContainerBuilder $container): void
{ {
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) { foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
+5 -4
View File
@@ -5,18 +5,19 @@ declare(strict_types=1);
namespace App\Tests\Unit; namespace App\Tests\Unit;
use App\Clock; use App\Clock;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ClockTest extends TestCase final class ClockTest extends TestCase
{ {
public function testNowReturnsDateTimeImmutable(): void public function test_now_returns_date_time_immutable(): void
{ {
$clock = new Clock(); $clock = new Clock();
$before = new \DateTimeImmutable(); $before = new DateTimeImmutable();
$now = $clock->now(); $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::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp());
self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp()); self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp());
} }
@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Tests\Unit\Command; namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand; use App\Command\GenerateBackupCodesCommand;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use App\PersistCache; use App\PersistCache;
use App\Service\BackupCodeInterface; use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase final class GenerateBackupCodesCommandTest extends TestCase
@@ -25,16 +25,17 @@ final class GenerateBackupCodesCommandTest extends TestCase
{ {
$manager = $this->createStub(BackupCodeInterface::class); $manager = $this->createStub(BackupCodeInterface::class);
$manager->method('generate')->willReturn($generatedCodes); $manager->method('generate')->willReturn($generatedCodes);
return $manager; return $manager;
} }
public function testGenerateDefaultCountOutputsCodes(): void public function test_generate_default_count_outputs_codes(): void
{ {
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345', $codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890']; 'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
$command = new GenerateBackupCodesCommand( $command = new GenerateBackupCodesCommand(
$this->makeManagerStub($codes), $this->makeManagerStub($codes),
$this->makePersistCache() $this->makePersistCache(),
); );
$command->setName('app:generate-backup-codes'); $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 = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once()) $manager->expects(self::once())
@@ -65,7 +66,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit); 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 // the configured default for the count argument should be 10
$manager = $this->createMock(BackupCodeInterface::class); $manager = $this->createMock(BackupCodeInterface::class);
@@ -84,7 +85,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
$this->addToAssertionCount(1); $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 // PersistCache is final and can't be mocked, but we can verify the
// command runs end-to-end with a real instance; boot()/persist() // command runs end-to-end with a real instance; boot()/persist()
@@ -92,7 +93,7 @@ final class GenerateBackupCodesCommandTest extends TestCase
// without throwing. // without throwing.
$command = new GenerateBackupCodesCommand( $command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['code1']), $this->makeManagerStub(['code1']),
$this->makePersistCache() $this->makePersistCache(),
); );
$command->setName('app:generate-backup-codes'); $command->setName('app:generate-backup-codes');
@@ -102,25 +103,25 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit); 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 // count must be a positive integer — zero is rejected
$command = new GenerateBackupCodesCommand( $command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makeManagerStub([]),
$this->makePersistCache() $this->makePersistCache(),
); );
$command->setName('app:generate-backup-codes'); $command->setName('app:generate-backup-codes');
$tester = new CommandTester($command); $tester = new CommandTester($command);
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
$tester->execute(['count' => 0]); $tester->execute(['count' => 0]);
} }
public function testCommandNameAndDescriptionAreConfigured(): void public function test_command_name_and_description_are_configured(): void
{ {
$command = new GenerateBackupCodesCommand( $command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['dummy']), $this->makeManagerStub(['dummy']),
$this->makePersistCache() $this->makePersistCache(),
); );
// configuring via the Application runs the protected configure() // configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application(); $app = new \Symfony\Component\Console\Application();
+9 -10
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Tests\Unit; namespace App\Tests\Unit;
use App\ConfigBag;
use App\Enum\RemoteUserMode; use App\Enum\RemoteUserMode;
use App\Tests\Support\TotpTestHelper; use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -13,14 +12,14 @@ final class ConfigBagRemoteUserTest extends TestCase
{ {
use TotpTestHelper; use TotpTestHelper;
public function testDefaultRemoteUserModeIsSession(): void public function test_default_remote_user_mode_is_session(): void
{ {
$config = $this->makeConfig(); $config = $this->makeConfig();
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode()); self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
} }
public function testStaticMode(): void public function test_static_mode(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'); $config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
@@ -28,7 +27,7 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame('authenticated', $config->remoteUserStatic()); self::assertSame('authenticated', $config->remoteUserStatic());
} }
public function testMappedMode(): void public function test_mapped_mode(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user'); $config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin,bob:user');
@@ -36,28 +35,28 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap()); self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
} }
public function testNoneMode(): void public function test_none_mode(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'none'); $config = $this->makeConfig(remoteUserMode: 'none');
self::assertSame(RemoteUserMode::None, $config->remoteUserMode()); self::assertSame(RemoteUserMode::None, $config->remoteUserMode());
} }
public function testInvalidModeFallsBackToSession(): void public function test_invalid_mode_falls_back_to_session(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'invalid-mode'); $config = $this->makeConfig(remoteUserMode: 'invalid-mode');
self::assertSame(RemoteUserMode::Session, $config->remoteUserMode()); self::assertSame(RemoteUserMode::Session, $config->remoteUserMode());
} }
public function testEmptyMapReturnsEmptyArray(): void public function test_empty_map_returns_empty_array(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: ''); $config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: '');
self::assertSame([], $config->remoteUserMap()); self::assertSame([], $config->remoteUserMap());
} }
public function testMapParsesWithWhitespace(): void public function test_map_parses_with_whitespace(): void
{ {
$config = $this->makeConfig( $config = $this->makeConfig(
remoteUserMode: 'mapped', remoteUserMode: 'mapped',
@@ -67,7 +66,7 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap()); self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
} }
public function testMapIgnoresInvalidEntries(): void public function test_map_ignores_invalid_entries(): void
{ {
$config = $this->makeConfig( $config = $this->makeConfig(
remoteUserMode: 'mapped', remoteUserMode: 'mapped',
@@ -77,7 +76,7 @@ final class ConfigBagRemoteUserTest extends TestCase
self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap()); self::assertSame(['alice' => 'admin', 'bob' => 'user'], $config->remoteUserMap());
} }
public function testMapPreservesColonsInValue(): void public function test_map_preserves_colons_in_value(): void
{ {
$config = $this->makeConfig( $config = $this->makeConfig(
remoteUserMode: 'mapped', remoteUserMode: 'mapped',
+5 -5
View File
@@ -18,7 +18,7 @@ final class ConfigBagTest extends TestCase
$clock = $this->createStub(ClockInterface::class); $clock = $this->createStub(ClockInterface::class);
$cache = $this->createStub(CacheItemPoolInterface::class); $cache = $this->createStub(CacheItemPoolInterface::class);
if ($totp !== null) { if (null !== $totp) {
$item = $this->createStub(CacheItemInterface::class); $item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true); $item->method('isHit')->willReturn(true);
$item->method('get')->willReturn($totp); $item->method('get')->willReturn($totp);
@@ -31,7 +31,7 @@ final class ConfigBagTest extends TestCase
return new Utilities($clock, $cache); return new Utilities($clock, $cache);
} }
public function testGettersWithExplicitValues(): void public function test_getters_with_explicit_values(): void
{ {
$clock = $this->createStub(ClockInterface::class); $clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities(); $utilities = $this->createUtilities();
@@ -61,7 +61,7 @@ final class ConfigBagTest extends TestCase
self::assertSame('Too Many!', $config->tooManyTitle()); self::assertSame('Too Many!', $config->tooManyTitle());
} }
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void public function test_totp_uri_falls_back_to_utilities_when_empty(): void
{ {
$clock = $this->createStub(ClockInterface::class); $clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities('fallback-totp'); $utilities = $this->createUtilities('fallback-totp');
@@ -84,7 +84,7 @@ final class ConfigBagTest extends TestCase
self::assertSame('fallback-totp', $config->totpUri()); self::assertSame('fallback-totp', $config->totpUri());
} }
public function testIpTtlFallsBackToNullWhenZero(): void public function test_ip_ttl_falls_back_to_null_when_zero(): void
{ {
$clock = $this->createStub(ClockInterface::class); $clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities(); $utilities = $this->createUtilities();
@@ -107,7 +107,7 @@ final class ConfigBagTest extends TestCase
self::assertNull($config->ipTtl()); self::assertNull($config->ipTtl());
} }
public function testIpTtlFallsBackToNullWhenNull(): void public function test_ip_ttl_falls_back_to_null_when_null(): void
{ {
$clock = $this->createStub(ClockInterface::class); $clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities(); $utilities = $this->createUtilities();
+38 -38
View File
@@ -16,7 +16,7 @@ final class PayloadTest extends TestCase
return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
} }
public function testDecodeValidBase64Url(): void public function test_decode_valid_base64_url(): void
{ {
$data = json_encode([ $data = json_encode([
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123', 'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
@@ -32,49 +32,49 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Cookie, $payload->scope); self::assertSame(Scope::Cookie, $payload->scope);
} }
public function testDecodeInvalidBase64UrlReturnsNull(): void public function test_decode_invalid_base64_url_returns_null(): void
{ {
self::assertNull(Payload::decode('!!!not-valid-base64!!!')); self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
} }
public function testDecodeNonObjectJsonReturnsNull(): void public function test_decode_non_object_json_returns_null(): void
{ {
self::assertNull(Payload::decode(self::b64u('"just a string"'))); self::assertNull(Payload::decode(self::b64u('"just a string"')));
} }
public function testDecodeInvalidJsonReturnsNull(): void public function test_decode_invalid_json_returns_null(): void
{ {
// valid base64url but invalid JSON // valid base64url but invalid JSON
self::assertNull(Payload::decode(self::b64u('{invalid json'))); self::assertNull(Payload::decode(self::b64u('{invalid json')));
} }
public function testDecodeJsonArrayReturnsNull(): void public function test_decode_json_array_returns_null(): void
{ {
self::assertNull(Payload::decode(self::b64u('[1,2,3]'))); self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
} }
public function testDecodeJsonNullReturnsNull(): void public function test_decode_json_null_returns_null(): void
{ {
self::assertNull(Payload::decode(self::b64u('null'))); self::assertNull(Payload::decode(self::b64u('null')));
} }
public function testDecodeJsonBooleanReturnsNull(): void public function test_decode_json_boolean_returns_null(): void
{ {
self::assertNull(Payload::decode(self::b64u('true'))); self::assertNull(Payload::decode(self::b64u('true')));
self::assertNull(Payload::decode(self::b64u('false'))); self::assertNull(Payload::decode(self::b64u('false')));
} }
public function testDecodeJsonNumberReturnsNull(): void public function test_decode_json_number_returns_null(): void
{ {
self::assertNull(Payload::decode(self::b64u('42'))); self::assertNull(Payload::decode(self::b64u('42')));
} }
public function testDecodeEmptyStringReturnsNull(): void public function test_decode_empty_string_returns_null(): void
{ {
self::assertNull(Payload::decode('')); self::assertNull(Payload::decode(''));
} }
public function testLoadWithValidInputBag(): void public function test_load_with_valid_input_bag(): void
{ {
$input = new InputBag([ $input = new InputBag([
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321', 'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
@@ -89,34 +89,34 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Cookie, $payload->scope); self::assertSame(Scope::Cookie, $payload->scope);
} }
public function testLoadMissingUsernameReturnsNull(): void public function test_load_missing_username_returns_null(): void
{ {
$input = new InputBag(['nonce' => 'n', 'totp' => 't']); $input = new InputBag(['nonce' => 'n', 'totp' => 't']);
self::assertNull(Payload::load($input)); self::assertNull(Payload::load($input));
} }
public function testLoadMissingNonceReturnsNull(): void public function test_load_missing_nonce_returns_null(): void
{ {
$input = new InputBag(['username' => 'u', 'totp' => 't']); $input = new InputBag(['username' => 'u', 'totp' => 't']);
self::assertNull(Payload::load($input)); self::assertNull(Payload::load($input));
} }
public function testLoadMissingTotpReturnsNull(): void public function test_load_missing_totp_returns_null(): void
{ {
$input = new InputBag(['username' => 'u', 'nonce' => 'n']); $input = new InputBag(['username' => 'u', 'nonce' => 'n']);
self::assertNull(Payload::load($input)); self::assertNull(Payload::load($input));
} }
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void public function test_load_with_all_fields_present_but_empty_returns_null(): void
{ {
// has() returns true for all, but create() rejects empty values // has() returns true for all, but create() rejects empty values
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']); $input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
self::assertNull(Payload::load($input)); self::assertNull(Payload::load($input));
} }
public function testCreateWithValidData(): void public function test_create_with_valid_data(): void
{ {
$data = (object)[ $data = (object) [
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => false, 'scope' => 'ip', 'json' => false, 'scope' => 'ip',
]; ];
@@ -130,16 +130,16 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Ip, $payload->scope); self::assertSame(Scope::Ip, $payload->scope);
} }
public function testCreateWithDefaultScope(): void public function test_create_with_default_scope(): void
{ {
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1']; $data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data); $payload = Payload::create($data);
self::assertSame(Scope::Cookie, $payload->scope); self::assertSame(Scope::Cookie, $payload->scope);
} }
public function testCreateWithInvalidScopeFallsBackToCookie(): void public function test_create_with_invalid_scope_falls_back_to_cookie(): void
{ {
$data = (object)[ $data = (object) [
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'scope' => 'admin', 'scope' => 'admin',
]; ];
@@ -147,16 +147,16 @@ final class PayloadTest extends TestCase
self::assertSame(Scope::Cookie, $payload->scope); self::assertSame(Scope::Cookie, $payload->scope);
} }
public function testCreateWithMissingJsonDefaultsToTrue(): void public function test_create_with_missing_json_defaults_to_true(): void
{ {
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1']; $data = (object) ['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data); $payload = Payload::create($data);
self::assertTrue($payload->json); self::assertTrue($payload->json);
} }
public function testCreateWithNoneScopeSetsJsonFalse(): void public function test_create_with_none_scope_sets_json_false(): void
{ {
$data = (object)[ $data = (object) [
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => true, 'scope' => 'none', 'json' => true, 'scope' => 'none',
]; ];
@@ -165,37 +165,37 @@ final class PayloadTest extends TestCase
self::assertFalse($payload->json); self::assertFalse($payload->json);
} }
public function testCreateWithEmptyIdReturnsNull(): void public function test_create_with_empty_id_returns_null(): void
{ {
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n']; $data = (object) ['id' => '', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data)); self::assertNull(Payload::create($data));
} }
public function testCreateWithWhitespaceIdReturnsNull(): void public function test_create_with_whitespace_id_returns_null(): void
{ {
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n']; $data = (object) ['id' => ' ', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data)); self::assertNull(Payload::create($data));
} }
public function testCreateWithEmptyTokenReturnsNull(): void public function test_create_with_empty_token_returns_null(): void
{ {
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n']; $data = (object) ['id' => 'u', 'token' => '', 'nonce' => 'n'];
self::assertNull(Payload::create($data)); self::assertNull(Payload::create($data));
} }
public function testCreateWithEmptyNonceReturnsNull(): void public function test_create_with_empty_nonce_returns_null(): void
{ {
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => '']; $data = (object) ['id' => 'u', 'token' => 't', 'nonce' => ''];
self::assertNull(Payload::create($data)); self::assertNull(Payload::create($data));
} }
public function testCreateTrimsAndTruncatesFields(): void public function test_create_trims_and_truncates_fields(): void
{ {
$long = str_repeat('a', 200); $long = str_repeat('a', 200);
$data = (object)[ $data = (object) [
'id' => ' ' . $long . ' ', 'id' => ' '.$long.' ',
'token' => ' ' . $long . ' ', 'token' => ' '.$long.' ',
'nonce' => ' ' . $long . ' ', 'nonce' => ' '.$long.' ',
]; ];
$payload = Payload::create($data); $payload = Payload::create($data);
$expected = mb_substr($long, 0, 128); $expected = mb_substr($long, 0, 128);
@@ -204,7 +204,7 @@ final class PayloadTest extends TestCase
self::assertSame($expected, $payload->nonce); self::assertSame($expected, $payload->nonce);
} }
public function testToString(): void public function test_to_string(): void
{ {
$payload = new Payload(); $payload = new Payload();
$payload->id = 'u'; $payload->id = 'u';
+3 -3
View File
@@ -9,21 +9,21 @@ use PHPUnit\Framework\TestCase;
final class ScopeTest extends TestCase final class ScopeTest extends TestCase
{ {
public function testCases(): void public function test_cases(): void
{ {
self::assertSame('cookie', Scope::Cookie->value); self::assertSame('cookie', Scope::Cookie->value);
self::assertSame('ip', Scope::Ip->value); self::assertSame('ip', Scope::Ip->value);
self::assertSame('none', Scope::None->value); self::assertSame('none', Scope::None->value);
} }
public function testTryFromValid(): void public function test_try_from_valid(): void
{ {
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie')); self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
self::assertSame(Scope::Ip, Scope::tryFrom('ip')); self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
self::assertSame(Scope::None, Scope::tryFrom('none')); self::assertSame(Scope::None, Scope::tryFrom('none'));
} }
public function testTryFromInvalid(): void public function test_try_from_invalid(): void
{ {
self::assertNull(Scope::tryFrom('invalid')); self::assertNull(Scope::tryFrom('invalid'));
self::assertNull(Scope::tryFrom('')); self::assertNull(Scope::tryFrom(''));
+19 -19
View File
@@ -12,7 +12,6 @@ use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\HttpKernelInterface;
@@ -30,13 +29,14 @@ final class AcceptListenerTest extends TestCase
): AcceptListener { ): AcceptListener {
$listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig()); $listener = new AcceptListener($pool, $domainManager, $config ?? $this->makeConfig());
$listener->setLogger(new NullLogger()); $listener->setLogger(new NullLogger());
return $listener; return $listener;
} }
private function makeEvent(Request $request): RequestEvent private function makeEvent(Request $request): RequestEvent
{ {
return new RequestEvent( return new RequestEvent(
$this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class), $this->createStub(HttpKernelInterface::class),
$request, $request,
HttpKernelInterface::MAIN_REQUEST, HttpKernelInterface::MAIN_REQUEST,
); );
@@ -44,11 +44,11 @@ final class AcceptListenerTest extends TestCase
/* ── valid cookie session ─────────────────────────────────────────── */ /* ── valid cookie session ─────────────────────────────────────────── */
public function testValidCookieSetsResponseWithRemoteUser(): void public function test_valid_cookie_sets_response_with_remote_user(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('alice'); $item->set('alice');
$pool->save($item); $pool->save($item);
@@ -68,11 +68,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('text/plain', $response->headers->get('Content-Type')); self::assertSame('text/plain', $response->headers->get('Content-Type'));
} }
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void public function test_valid_cookie_uses_auth_cookie_name_when_using_central_auth(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('bob'); $item->set('bob');
$pool->save($item); $pool->save($item);
@@ -91,7 +91,7 @@ final class AcceptListenerTest extends TestCase
/* ── negative cases ───────────────────────────────────────────────── */ /* ── negative cases ───────────────────────────────────────────────── */
public function testNoCookieSetsNoResponse(): void public function test_no_cookie_sets_no_response(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
@@ -103,7 +103,7 @@ final class AcceptListenerTest extends TestCase
self::assertFalse($event->hasResponse()); self::assertFalse($event->hasResponse());
} }
public function testCookieWithoutSessionSetsNoResponse(): void public function test_cookie_without_session_sets_no_response(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
@@ -118,7 +118,7 @@ final class AcceptListenerTest extends TestCase
self::assertFalse($event->hasResponse()); self::assertFalse($event->hasResponse());
} }
public function testEmptyCookieValueSetsNoResponse(): void public function test_empty_cookie_value_sets_no_response(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
@@ -137,11 +137,11 @@ final class AcceptListenerTest extends TestCase
/* ── Remote-User header modes ─────────────────────────────────────── */ /* ── Remote-User header modes ─────────────────────────────────────── */
public function testRemoteUserSessionModeSendsSessionId(): void public function test_remote_user_session_mode_sends_session_id(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('alice'); $item->set('alice');
$pool->save($item); $pool->save($item);
@@ -162,11 +162,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('alice', $event->getResponse()->headers->get('Remote-User')); self::assertSame('alice', $event->getResponse()->headers->get('Remote-User'));
} }
public function testRemoteUserStaticModeSendsFixedValue(): void public function test_remote_user_static_mode_sends_fixed_value(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('alice'); $item->set('alice');
$pool->save($item); $pool->save($item);
@@ -187,11 +187,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User')); self::assertSame('authenticated', $event->getResponse()->headers->get('Remote-User'));
} }
public function testRemoteUserMappedModeSendsMappedValue(): void public function test_remote_user_mapped_mode_sends_mapped_value(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('alice'); $item->set('alice');
$pool->save($item); $pool->save($item);
@@ -212,11 +212,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('admin', $event->getResponse()->headers->get('Remote-User')); self::assertSame('admin', $event->getResponse()->headers->get('Remote-User'));
} }
public function testRemoteUserMappedModeFallsBackToSessionIdWhenNotInMap(): void public function test_remote_user_mapped_mode_falls_back_to_session_id_when_not_in_map(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('unknown_user'); $item->set('unknown_user');
$pool->save($item); $pool->save($item);
@@ -237,11 +237,11 @@ final class AcceptListenerTest extends TestCase
self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User')); self::assertSame('unknown_user', $event->getResponse()->headers->get('Remote-User'));
} }
public function testRemoteUserNoneModeOmitsHeader(): void public function test_remote_user_none_mode_omits_header(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK'; $ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid); $item = $pool->getItem('cookie_'.$ulid);
$item->set('alice'); $item->set('alice');
$pool->save($item); $pool->save($item);
+5 -4
View File
@@ -22,6 +22,7 @@ final class AllowListenerTest extends TestCase
{ {
$listener = new AllowListener($pool, $config); $listener = new AllowListener($pool, $config);
$listener->setLogger(new NullLogger()); $listener->setLogger(new NullLogger());
return $listener; return $listener;
} }
@@ -34,7 +35,7 @@ final class AllowListenerTest extends TestCase
); );
} }
public function testValidIpSessionSetsResponseWithRemoteUser(): void public function test_valid_ip_session_sets_response_with_remote_user(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$item = $pool->getItem('ip_1.2.3.4'); $item = $pool->getItem('ip_1.2.3.4');
@@ -55,7 +56,7 @@ final class AllowListenerTest extends TestCase
self::assertSame('text/plain', $response->headers->get('Content-Type')); self::assertSame('text/plain', $response->headers->get('Content-Type'));
} }
public function testNoIpSessionSetsNoResponse(): void public function test_no_ip_session_sets_no_response(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 1800); $config = $this->makeConfig(ipTtl: 1800);
@@ -68,7 +69,7 @@ final class AllowListenerTest extends TestCase
self::assertFalse($event->hasResponse()); self::assertFalse($event->hasResponse());
} }
public function testIpAccessDisabledSetsNoResponse(): void public function test_ip_access_disabled_sets_no_response(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
// even though there's a stored session, ip access is disabled // even though there's a stored session, ip access is disabled
@@ -86,7 +87,7 @@ final class AllowListenerTest extends TestCase
self::assertFalse($event->hasResponse()); self::assertFalse($event->hasResponse());
} }
public function testIpAccessDisabledDoesNotCheckCache(): void public function test_ip_access_disabled_does_not_check_cache(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 0); $config = $this->makeConfig(ipTtl: 0);
+13 -12
View File
@@ -35,6 +35,7 @@ final class InterceptListenerTest extends TestCase
); );
$listener->setLogger(new NullLogger()); $listener->setLogger(new NullLogger());
$listener->setNonceCache($nonceCache ?? new ArrayAdapter()); $listener->setNonceCache($nonceCache ?? new ArrayAdapter());
return $listener; return $listener;
} }
@@ -49,7 +50,7 @@ final class InterceptListenerTest extends TestCase
/* ── central-auth redirect branch ─────────────────────────────────── */ /* ── central-auth redirect branch ─────────────────────────────────── */
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void public function test_redirects_to_auth_subdomain_when_host_matches_base_domain(): void
{ {
$domainManager = new DomainManager(true, 'auth.example.com'); $domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -68,7 +69,7 @@ final class InterceptListenerTest extends TestCase
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location); self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
} }
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void public function test_does_not_redirect_when_already_on_auth_subdomain(): void
{ {
$domainManager = new DomainManager(true, 'auth.example.com'); $domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -86,7 +87,7 @@ final class InterceptListenerTest extends TestCase
/* ── login page rendering branch ──────────────────────────────────── */ /* ── login page rendering branch ──────────────────────────────────── */
public function testPresentsLoginPageWithUnauthorizedStatus(): void public function test_presents_login_page_with_unauthorized_status(): void
{ {
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -105,7 +106,7 @@ final class InterceptListenerTest extends TestCase
self::assertStringContainsString('name="nonce"', $content); self::assertStringContainsString('name="nonce"', $content);
} }
public function testGeneratedNonceIsStoredInCache(): void public function test_generated_nonce_is_stored_in_cache(): void
{ {
$nonceCache = new ArrayAdapter(); $nonceCache = new ArrayAdapter();
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
@@ -123,10 +124,10 @@ final class InterceptListenerTest extends TestCase
} }
} }
// ArrayAdapter stores raw values; verify at least one item was saved // ArrayAdapter stores raw values; verify at least one item was saved
self::assertTrue(count($nonceCache->getValues()) > 0); self::assertTrue(\count($nonceCache->getValues()) > 0);
} }
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void public function test_login_template_uses_post_form_when_on_auth_subdomain(): void
{ {
$domainManager = new DomainManager(true, 'auth.example.com'); $domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -140,7 +141,7 @@ final class InterceptListenerTest extends TestCase
self::assertStringContainsString('method="post"', $content); self::assertStringContainsString('method="post"', $content);
} }
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void public function test_login_template_does_not_use_post_form_when_not_on_auth_subdomain(): void
{ {
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -156,7 +157,7 @@ final class InterceptListenerTest extends TestCase
/* ── invalid cookie pruning ───────────────────────────────────────── */ /* ── invalid cookie pruning ───────────────────────────────────────── */
public function testInvalidCookieIsClearedWhenPresent(): void public function test_invalid_cookie_is_cleared_when_present(): void
{ {
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -174,14 +175,14 @@ final class InterceptListenerTest extends TestCase
$cookies = $response->headers->getCookies(); $cookies = $response->headers->getCookies();
$cleared = false; $cleared = false;
foreach ($cookies as $cookie) { foreach ($cookies as $cookie) {
if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) { if (self::COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true; $cleared = true;
} }
} }
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared'); self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
} }
public function testNoCookieClearingWhenNoCookiePresent(): void public function test_no_cookie_clearing_when_no_cookie_present(): void
{ {
$domainManager = new DomainManager(false, ''); $domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -194,7 +195,7 @@ final class InterceptListenerTest extends TestCase
self::assertSame([], $response->headers->getCookies()); self::assertSame([], $response->headers->getCookies());
} }
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void public function test_invalid_cookie_uses_auth_cookie_name_with_central_auth(): void
{ {
$domainManager = new DomainManager(true, 'auth.example.com'); $domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager); $listener = $this->makeListener($domainManager);
@@ -209,7 +210,7 @@ final class InterceptListenerTest extends TestCase
$response = $event->getResponse(); $response = $event->getResponse();
$cleared = false; $cleared = false;
foreach ($response->headers->getCookies() as $cookie) { foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === self::AUTH_COOKIE_NAME && $cookie->isCleared()) { if (self::AUTH_COOKIE_NAME === $cookie->getName() && $cookie->isCleared()) {
$cleared = true; $cleared = true;
} }
} }
+14 -13
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener; namespace App\Tests\Unit\Listener;
use App\Data\Payload; use App\Data\Payload;
use App\Enum\Scope;
use App\Listener\LoginListener; use App\Listener\LoginListener;
use App\Service\DomainManager; use App\Service\DomainManager;
use App\Service\LoginInterface; use App\Service\LoginInterface;
@@ -38,6 +37,7 @@ final class LoginListenerTest extends TestCase
); );
$listener->setLogger(new NullLogger()); $listener->setLogger(new NullLogger());
$listener->setNonceCache(new ArrayAdapter()); $listener->setNonceCache(new ArrayAdapter());
return $listener; return $listener;
} }
@@ -53,13 +53,14 @@ final class LoginListenerTest extends TestCase
/** Build a base64url-encoded X-Preauth header value for a payload. */ /** Build a base64url-encoded X-Preauth header value for a payload. */
private function encodePayload(array $data): string 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), '+/', '-_'), '='); return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
} }
/* ── no login attempt ─────────────────────────────────────────────── */ /* ── no login attempt ─────────────────────────────────────────────── */
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void public function test_no_header_and_no_post_returns_early_without_response(): void
{ {
$listener = $this->makeListener(); $listener = $this->makeListener();
@@ -70,7 +71,7 @@ final class LoginListenerTest extends TestCase
self::assertFalse($event->hasResponse()); self::assertFalse($event->hasResponse());
} }
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void public function test_post_to_non_auth_subdomain_returns_early_without_response(): void
{ {
// POST only counts as a login attempt when on the auth subdomain // POST only counts as a login attempt when on the auth subdomain
$domainManager = new DomainManager(true, 'auth.example.com'); $domainManager = new DomainManager(true, 'auth.example.com');
@@ -85,7 +86,7 @@ final class LoginListenerTest extends TestCase
/* ── successful login via header ──────────────────────────────────── */ /* ── successful login via header ──────────────────────────────────── */
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void public function test_successful_login_via_header_sets_response_from_manager(): void
{ {
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']); $expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
@@ -106,7 +107,7 @@ final class LoginListenerTest extends TestCase
self::assertSame($expected, $event->getResponse()); self::assertSame($expected, $event->getResponse());
} }
public function testSuccessfulLoginViaPostToAuthSubdomain(): void public function test_successful_login_via_post_to_auth_subdomain(): void
{ {
$expected = new Response('hi bob', 303, ['Location' => '/']); $expected = new Response('hi bob', 303, ['Location' => '/']);
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
@@ -128,7 +129,7 @@ final class LoginListenerTest extends TestCase
/* ── failed login ─────────────────────────────────────────────────── */ /* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void public function test_failed_login_returns_json_error_with_new_nonce(): void
{ {
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null); $loginManager->method('checkToken')->willReturn(null);
@@ -157,7 +158,7 @@ final class LoginListenerTest extends TestCase
self::assertSame('alice', $body['username']); self::assertSame('alice', $body['username']);
} }
public function testFailedLoginHtmlResponseWhenJsonFalse(): void public function test_failed_login_html_response_when_json_false(): void
{ {
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null); $loginManager->method('checkToken')->willReturn(null);
@@ -179,7 +180,7 @@ final class LoginListenerTest extends TestCase
self::assertStringContainsString('<form', $response->getContent()); self::assertStringContainsString('<form', $response->getContent());
} }
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void public function test_failed_login_on_auth_subdomain_uses_post_form(): void
{ {
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null); $loginManager->method('checkToken')->willReturn(null);
@@ -205,7 +206,7 @@ final class LoginListenerTest extends TestCase
/* ── rate-limited (blocked) login ─────────────────────────────────── */ /* ── rate-limited (blocked) login ─────────────────────────────────── */
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void public function test_rate_limited_login_returns_teapot_when_teapot_enabled(): void
{ {
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null); $loginManager->method('checkToken')->willReturn(null);
@@ -232,7 +233,7 @@ final class LoginListenerTest extends TestCase
self::assertSame('Teapot', $body['message']); self::assertSame('Teapot', $body['message']);
} }
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void public function test_rate_limited_login_returns_too_many_requests_when_teapot_disabled(): void
{ {
$loginManager = $this->createStub(LoginInterface::class); $loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null); $loginManager->method('checkToken')->willReturn(null);
@@ -265,7 +266,7 @@ final class LoginListenerTest extends TestCase
/* ── invalid payload handling ─────────────────────────────────────── */ /* ── invalid payload handling ─────────────────────────────────────── */
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void public function test_invalid_header_payload_still_records_failure_and_responds(): void
{ {
$loginManager = $this->createMock(LoginInterface::class); $loginManager = $this->createMock(LoginInterface::class);
// checkToken should not be called with a null payload // checkToken should not be called with a null payload
@@ -286,7 +287,7 @@ final class LoginListenerTest extends TestCase
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode()); self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
} }
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void public function test_post_without_required_fields_does_not_attempt_login(): void
{ {
$loginManager = $this->createMock(LoginInterface::class); $loginManager = $this->createMock(LoginInterface::class);
$loginManager->expects(self::never())->method('checkToken'); $loginManager->expects(self::never())->method('checkToken');
@@ -41,6 +41,7 @@ final class PublicAccessListenerTest extends TestCase
$this->makeRateLimiterFactory($remainingTokens), $this->makeRateLimiterFactory($remainingTokens),
); );
$listener->setLogger(new NullLogger()); $listener->setLogger(new NullLogger());
return $listener; return $listener;
} }
@@ -55,7 +56,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── feature disabled ──────────────────────────────────────────────── */ /* ── feature disabled ──────────────────────────────────────────────── */
public function testNoPublicPathsReturnsWithoutResponse(): void public function test_no_public_paths_returns_without_response(): void
{ {
$listener = $this->makeListener(publicPaths: ''); $listener = $this->makeListener(publicPaths: '');
@@ -68,7 +69,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── non-public path ───────────────────────────────────────────────── */ /* ── non-public path ───────────────────────────────────────────────── */
public function testNonPublicPathReturnsWithoutResponse(): void public function test_non_public_path_returns_without_response(): void
{ {
$listener = $this->makeListener(publicPaths: '/public/**'); $listener = $this->makeListener(publicPaths: '/public/**');
@@ -81,7 +82,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── public path within rate limit ─────────────────────────────────── */ /* ── public path within rate limit ─────────────────────────────────── */
public function testPublicPathWithinRateLimitReturns200(): void public function test_public_path_within_rate_limit_returns200(): void
{ {
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10); $listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
@@ -99,7 +100,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── public path rate limited ──────────────────────────────────────── */ /* ── public path rate limited ──────────────────────────────────────── */
public function testPublicPathOverRateLimitReturns429(): void public function test_public_path_over_rate_limit_returns429(): void
{ {
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0); $listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
@@ -114,7 +115,7 @@ final class PublicAccessListenerTest extends TestCase
self::assertTrue($response->headers->has('Retry-After')); self::assertTrue($response->headers->has('Retry-After'));
} }
public function testRateLimitedResponseContainsErrorTemplate(): void public function test_rate_limited_response_contains_error_template(): void
{ {
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0); $listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
@@ -129,7 +130,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── auth subdomain is never public ────────────────────────────────── */ /* ── auth subdomain is never public ────────────────────────────────── */
public function testAuthSubdomainRequestIsSkipped(): void public function test_auth_subdomain_request_is_skipped(): void
{ {
$listener = $this->makeListener( $listener = $this->makeListener(
publicPaths: '/**', publicPaths: '/**',
@@ -147,7 +148,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── query string is ignored ───────────────────────────────────────── */ /* ── query string is ignored ───────────────────────────────────────── */
public function testQueryStringIsIgnoredForPathMatching(): void public function test_query_string_is_ignored_for_path_matching(): void
{ {
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10); $listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
@@ -161,7 +162,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── domain-scoped paths ───────────────────────────────────────────── */ /* ── domain-scoped paths ───────────────────────────────────────────── */
public function testDomainScopedPathMatchesCorrectHost(): void public function test_domain_scoped_path_matches_correct_host(): void
{ {
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10); $listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
@@ -173,7 +174,7 @@ final class PublicAccessListenerTest extends TestCase
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode()); self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
} }
public function testDomainScopedPathDoesNotMatchOtherHost(): void public function test_domain_scoped_path_does_not_match_other_host(): void
{ {
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10); $listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
@@ -186,7 +187,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── wildcard matching ─────────────────────────────────────────────── */ /* ── wildcard matching ─────────────────────────────────────────────── */
public function testSingleWildcardMatching(): void public function test_single_wildcard_matching(): void
{ {
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10); $listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
@@ -198,7 +199,7 @@ final class PublicAccessListenerTest extends TestCase
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode()); self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
} }
public function testSingleWildcardDoesNotMatchDeepPath(): void public function test_single_wildcard_does_not_match_deep_path(): void
{ {
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10); $listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
@@ -211,7 +212,7 @@ final class PublicAccessListenerTest extends TestCase
/* ── 200 response includes remaining token count ───────────────────── */ /* ── 200 response includes remaining token count ───────────────────── */
public function testOkResponseIncludesRetryAfterHeader(): void public function test_ok_response_includes_retry_after_header(): void
{ {
// The 200 response includes a Retry-After header showing remaining tokens // The 200 response includes a Retry-After header showing remaining tokens
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42); $listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
+5 -5
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Tests\Unit\Listener; namespace App\Tests\Unit\Listener;
use App\Listener\RejectListener; use App\Listener\RejectListener;
use App\Service\DomainManager;
use App\Tests\Support\ListenerTestHelper; use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
@@ -28,6 +27,7 @@ final class RejectListenerTest extends TestCase
$this->makeRateLimiterFactory($remainingTokens), $this->makeRateLimiterFactory($remainingTokens),
); );
$listener->setLogger(new NullLogger()); $listener->setLogger(new NullLogger());
return $listener; return $listener;
} }
@@ -40,7 +40,7 @@ final class RejectListenerTest extends TestCase
); );
} }
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void public function test_blocked_request_returns_teapot_when_teapot_enabled(): void
{ {
$listener = $this->makeListener(teapot: true, remainingTokens: 0); $listener = $this->makeListener(teapot: true, remainingTokens: 0);
@@ -54,7 +54,7 @@ final class RejectListenerTest extends TestCase
self::assertSame('text/html', $response->headers->get('Content-Type')); self::assertSame('text/html', $response->headers->get('Content-Type'));
} }
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void public function test_blocked_request_returns_too_many_requests_when_teapot_disabled(): void
{ {
$listener = $this->makeListener(teapot: false, remainingTokens: 0); $listener = $this->makeListener(teapot: false, remainingTokens: 0);
@@ -68,7 +68,7 @@ final class RejectListenerTest extends TestCase
self::assertSame('text/html', $response->headers->get('Content-Type')); self::assertSame('text/html', $response->headers->get('Content-Type'));
} }
public function testUnblockedRequestSetsNoResponse(): void public function test_unblocked_request_sets_no_response(): void
{ {
$listener = $this->makeListener(remainingTokens: 5); $listener = $this->makeListener(remainingTokens: 5);
@@ -80,7 +80,7 @@ final class RejectListenerTest extends TestCase
self::assertFalse($event->hasResponse()); self::assertFalse($event->hasResponse());
} }
public function testBlockedResponseContainsErrorTemplateContent(): void public function test_blocked_response_contains_error_template_content(): void
{ {
$listener = $this->makeListener(teapot: true, remainingTokens: 0); $listener = $this->makeListener(teapot: true, remainingTokens: 0);
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\SecurityHeadersListener;
use App\Service\DomainInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class SecurityHeadersListenerTest extends TestCase
{
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
{
$domainManager = $this->createStub(DomainInterface::class);
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
return new SecurityHeadersListener($domainManager);
}
private function makeEvent(
Response $response,
?Request $request = null,
int $requestType = HttpKernelInterface::MAIN_REQUEST,
): ResponseEvent {
return new ResponseEvent(
$this->createStub(HttpKernelInterface::class),
$request ?? Request::create('https://example.com/', 'GET'),
$requestType,
$response,
);
}
/**
* The emitted Cache-Control is normalized by Symfony (directives are
* reordered), so assert on directives rather than the exact string.
*/
private function assertNoStoreHeaders(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 assertNoAntiCachingHeaders(Response $response): void
{
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertNull($response->headers->get('Pragma'));
self::assertNull($response->headers->get('Expires'));
self::assertNull($response->headers->get('Surrogate-Control'));
self::assertNull($response->headers->get('Vary'));
}
/* ── non-2xx: the login flow must not be cacheable ────────────────── */
public function test_login_page_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
$this->assertNoStoreHeaders($response);
}
public function test_redirect_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('', Response::HTTP_SEE_OTHER, [
'Location' => 'https://example.com/dashboard',
]);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
$this->assertNoStoreHeaders($response);
// the redirect target must survive
self::assertSame('https://example.com/dashboard', $response->headers->get('Location'));
}
public function test_rate_limited_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('<h1>teapot</h1>', Response::HTTP_I_AM_A_TEAPOT);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
$this->assertNoStoreHeaders($response);
}
public function test_server_error_response_is_not_cacheable(): void
{
$listener = $this->makeListener();
$response = new Response('error', Response::HTTP_INTERNAL_SERVER_ERROR);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
$this->assertNoStoreHeaders($response);
}
/* ── 2xx: authenticated / public grants stay untouched ────────────── */
public function test_successful_authenticated_response_is_not_touched(): void
{
$listener = $this->makeListener();
$response = new Response('hi alice', Response::HTTP_OK, [
'Remote-User' => 'alice',
'Content-Type' => 'text/plain',
]);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
// "already authenticated" responses are consumed by the reverse
// proxy's forward_auth check and never reach the browser, so they
// must not carry the anti-caching headers (or they could leak onto
// the protected service's own responses in custom configurations)
$this->assertNoAntiCachingHeaders($response);
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function test_successful_response_keeps_its_own_cache_headers(): void
{
$listener = $this->makeListener();
$response = new Response('ok', Response::HTTP_OK, [
'Cache-Control' => 'public, max-age=60',
]);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
// the service's caching decisions are its own business;
// Symfony normalizes directive order, so assert semantically
self::assertTrue($response->headers->hasCacheControlDirective('public'));
self::assertSame('60', $response->headers->getCacheControlDirective('max-age'));
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
}
/* ── sub-requests ─────────────────────────────────────────────────── */
public function test_sub_requests_are_skipped(): void
{
$listener = $this->makeListener();
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response, null, HttpKernelInterface::SUB_REQUEST);
$listener->onKernelResponse($event);
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertNull($response->headers->get('X-Frame-Options'));
}
/* ── the pre-existing security headers ────────────────────────────── */
public function test_security_headers_are_applied(): void
{
$listener = $this->makeListener();
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
self::assertSame('strict-origin-when-cross-origin', $response->headers->get('Referrer-Policy'));
self::assertSame('max-age=31536000', $response->headers->get('Strict-Transport-Security'));
}
public function test_csp_allows_same_origin_connect_when_inline_script_is_used(): void
{
// not on the auth subdomain: the login form uses an inline fetch()
$listener = $this->makeListener('auth.example.com');
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
self::assertStringContainsString("connect-src 'self';", $response->headers->get('Content-Security-Policy'));
}
public function test_csp_does_not_allow_connect_when_on_auth_subdomain(): void
{
// on the auth subdomain the form POSTs normally — no inline fetch
$listener = $this->makeListener('auth.example.com');
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$request = Request::create('https://auth.example.com/', 'GET');
$event = $this->makeEvent($response, $request);
$listener->onKernelResponse($event);
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
}
}
+26 -25
View File
@@ -14,10 +14,11 @@ final class MonitorCacheKeysTest extends TestCase
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
{ {
$pool ??= new ArrayAdapter(); $pool ??= new ArrayAdapter();
return new MonitorCacheKeys($pool); return new MonitorCacheKeys($pool);
} }
public function testConstructorInitializesEmptyPool(): void public function test_constructor_initializes_empty_pool(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -25,7 +26,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor->getChanges()); self::assertSame([], $monitor->getChanges());
} }
public function testSaveAddsKeyAndTracksChange(): void public function test_save_adds_key_and_tracks_change(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('alpha'); $item = $monitor->getItem('alpha');
@@ -36,7 +37,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
} }
public function testSaveDeferredThenCommitAddsKey(): void public function test_save_deferred_then_commit_adds_key(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('beta'); $item = $monitor->getItem('beta');
@@ -48,7 +49,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
} }
public function testGetItemReturnsUnderlyingItem(): void public function test_get_item_returns_underlying_item(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('mykey'); $item = $monitor->getItem('mykey');
@@ -60,7 +61,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame('data', $fetched->get()); self::assertSame('data', $fetched->get());
} }
public function testGetItemsReturnsMultipleItems(): void public function test_get_items_returns_multiple_items(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$a = $monitor->getItem('a'); $a = $monitor->getItem('a');
@@ -78,7 +79,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['a' => 1, 'b' => 2], $keys); self::assertSame(['a' => 1, 'b' => 2], $keys);
} }
public function testHasItemReturnsTrueForExistingKey(): void public function test_has_item_returns_true_for_existing_key(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('exists'); $item = $monitor->getItem('exists');
@@ -89,7 +90,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertFalse($monitor->hasItem('missing')); self::assertFalse($monitor->hasItem('missing'));
} }
public function testDeleteItemRemovesKeyAndTracksRemoval(): void public function test_delete_item_removes_key_and_tracks_removal(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('doomed'); $item = $monitor->getItem('doomed');
@@ -103,7 +104,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertFalse($monitor->hasItem('doomed')); self::assertFalse($monitor->hasItem('doomed'));
} }
public function testDeleteItemOnMissingKeyIsNoop(): void public function test_delete_item_on_missing_key_is_noop(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -113,7 +114,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor->getKeys()); self::assertSame([], $monitor->getKeys());
} }
public function testDeleteItemsRemovesMultipleKeys(): void public function test_delete_items_removes_multiple_keys(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
foreach (['x', 'y', 'z'] as $key) { foreach (['x', 'y', 'z'] as $key) {
@@ -130,7 +131,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']); self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
} }
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void public function test_delete_items_with_missing_keys_still_returns_true(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -139,7 +140,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertTrue($result); self::assertTrue($result);
} }
public function testClearWipesPoolWhenNotEmpty(): void public function test_clear_wipes_pool_when_not_empty(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('keep'); $item = $monitor->getItem('keep');
@@ -152,7 +153,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor->getKeys()); self::assertSame([], $monitor->getKeys());
} }
public function testClearIsNoopWhenEmpty(): void public function test_clear_is_noop_when_empty(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -161,7 +162,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertTrue($result); self::assertTrue($result);
} }
public function testMarkCleanResetsChangeList(): void public function test_mark_clean_resets_change_list(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('temp'); $item = $monitor->getItem('temp');
@@ -176,14 +177,14 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame(['temp'], $monitor->getKeys()); self::assertSame(['temp'], $monitor->getKeys());
} }
public function testCommitPassesThrough(): void public function test_commit_passes_through(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
self::assertTrue($monitor->commit()); self::assertTrue($monitor->commit());
} }
public function testSaveKeyListThrowsOutOfBoundsException(): void public function test_save_key_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('__key_list'); $item = $monitor->getItem('__key_list');
@@ -192,7 +193,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->save($item); $monitor->save($item);
} }
public function testSaveChangeListThrowsOutOfBoundsException(): void public function test_save_change_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('__chg_list'); $item = $monitor->getItem('__chg_list');
@@ -201,7 +202,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->save($item); $monitor->save($item);
} }
public function testDeleteKeyListThrowsOutOfBoundsException(): void public function test_delete_key_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -209,7 +210,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItem('__key_list'); $monitor->deleteItem('__key_list');
} }
public function testDeleteChangeListThrowsOutOfBoundsException(): void public function test_delete_change_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -217,7 +218,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItem('__chg_list'); $monitor->deleteItem('__chg_list');
} }
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void public function test_delete_items_with_key_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -225,7 +226,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItems(['safe', '__key_list']); $monitor->deleteItems(['safe', '__key_list']);
} }
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void public function test_delete_items_with_change_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
@@ -233,7 +234,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->deleteItems(['__chg_list']); $monitor->deleteItems(['__chg_list']);
} }
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void public function test_save_deferred_on_key_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('safe'); $item = $monitor->getItem('safe');
@@ -247,7 +248,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->saveDeferred($keyListItem); $monitor->saveDeferred($keyListItem);
} }
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void public function test_save_deferred_on_change_list_throws_out_of_bounds_exception(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$changeListItem = $monitor->getItem('__chg_list'); $changeListItem = $monitor->getItem('__chg_list');
@@ -256,7 +257,7 @@ final class MonitorCacheKeysTest extends TestCase
$monitor->saveDeferred($changeListItem); $monitor->saveDeferred($changeListItem);
} }
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void public function test_get_keys_returns_empty_array_when_key_list_missing(): void
{ {
// If the underlying pool loses its key list, getKeys should return [] // If the underlying pool loses its key list, getKeys should return []
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
@@ -275,7 +276,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertSame([], $monitor2->getKeys()); self::assertSame([], $monitor2->getKeys());
} }
public function testDeleteItemReturnsTrueForExistingKey(): void public function test_delete_item_returns_true_for_existing_key(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
$item = $monitor->getItem('to-delete'); $item = $monitor->getItem('to-delete');
@@ -286,7 +287,7 @@ final class MonitorCacheKeysTest extends TestCase
self::assertNotContains('to-delete', $monitor->getKeys()); self::assertNotContains('to-delete', $monitor->getKeys());
} }
public function testDeleteItemsReturnsTrue(): void public function test_delete_items_returns_true(): void
{ {
$monitor = $this->wrap(); $monitor = $this->wrap();
foreach (['a', 'b', 'c'] as $key) { foreach (['a', 'b', 'c'] as $key) {
+9 -9
View File
@@ -11,7 +11,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class PersistCacheTest extends TestCase final class PersistCacheTest extends TestCase
{ {
public function testBootWithEmptyStorageIsNoop(): void public function test_boot_with_empty_storage_is_noop(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -24,7 +24,7 @@ final class PersistCacheTest extends TestCase
self::assertSame([], $monitor->getKeys()); self::assertSame([], $monitor->getKeys());
} }
public function testBootLoadsFromStorageIntoCache(): void public function test_boot_loads_from_storage_into_cache(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -47,7 +47,7 @@ final class PersistCacheTest extends TestCase
self::assertSame([], $cacheMonitor->getChanges()); self::assertSame([], $cacheMonitor->getChanges());
} }
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void public function test_boot_does_not_reload_when_cache_already_warm(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -73,7 +73,7 @@ final class PersistCacheTest extends TestCase
self::assertNotContains('cookie_new', $monitor->getKeys()); self::assertNotContains('cookie_new', $monitor->getKeys());
} }
public function testPersistWritesChangesToStorage(): void public function test_persist_writes_changes_to_storage(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -95,7 +95,7 @@ final class PersistCacheTest extends TestCase
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get()); self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
} }
public function testPersistHandlesRemovals(): void public function test_persist_handles_removals(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -121,7 +121,7 @@ final class PersistCacheTest extends TestCase
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys()); self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
} }
public function testPersistIsNoopWhenNoChanges(): void public function test_persist_is_noop_when_no_changes(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -134,7 +134,7 @@ final class PersistCacheTest extends TestCase
self::assertSame([], $storageMonitor->getKeys()); self::assertSame([], $storageMonitor->getKeys());
} }
public function testFullBootModifyPersistCycle(): void public function test_full_boot_modify_persist_cycle(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -160,7 +160,7 @@ final class PersistCacheTest extends TestCase
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get()); self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
} }
public function testPersistHandlesMixedUpdatesAndRemovals(): void public function test_persist_handles_mixed_updates_and_removals(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
@@ -194,7 +194,7 @@ final class PersistCacheTest extends TestCase
self::assertNotContains('cookie_remove', $storageMonitor->getKeys()); self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
} }
public function testMultipleBootModifyPersistCycles(): void public function test_multiple_boot_modify_persist_cycles(): void
{ {
$sessionCache = new ArrayAdapter(); $sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter(); $sessionStorage = new ArrayAdapter();
+28 -26
View File
@@ -6,6 +6,7 @@ namespace App\Tests\Unit\Service;
use App\Service\BackupCodeManager; use App\Service\BackupCodeManager;
use App\Tests\Support\TotpTestHelper; use App\Tests\Support\TotpTestHelper;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -20,10 +21,11 @@ final class BackupCodeManagerTest extends TestCase
$manager = new BackupCodeManager($pool); $manager = new BackupCodeManager($pool);
$manager->setConfig($this->makeConfig()); $manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger()); $manager->setLogger(new NullLogger());
return $manager; return $manager;
} }
public function testGenerateReturnsRequestedCount(): void public function test_generate_returns_requested_count(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
@@ -37,7 +39,7 @@ final class BackupCodeManagerTest extends TestCase
} }
} }
public function testGenerateDefaultCount(): void public function test_generate_default_count(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
@@ -46,7 +48,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertCount(10, $codes); self::assertCount(10, $codes);
} }
public function testGenerateZeroReturnsEmptyArray(): void public function test_generate_zero_returns_empty_array(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
@@ -55,7 +57,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertSame([], $codes); self::assertSame([], $codes);
} }
public function testGeneratedCodesAreStoredInCache(): void public function test_generated_codes_are_stored_in_cache(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -64,15 +66,15 @@ final class BackupCodeManagerTest extends TestCase
// each code should be stored as a backup_ key // each code should be stored as a backup_ key
foreach ($codes as $code) { foreach ($codes as $code) {
$key = 'backup_' . strtolower($code); $key = 'backup_'.strtolower($code);
// the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity // the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity
$item = $pool->getItem($key); $item = $pool->getItem($key);
self::assertTrue($item->isHit(), "Expected cache hit for key: $key"); self::assertTrue($item->isHit(), "Expected cache hit for key: $key");
self::assertTrue($item->get(), "Expected code to be marked valid (true)"); self::assertTrue($item->get(), 'Expected code to be marked valid (true)');
} }
} }
public function testGeneratedCodesHaveFarFutureExpiry(): void public function test_generated_codes_have_far_future_expiry(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -80,12 +82,12 @@ final class BackupCodeManagerTest extends TestCase
$codes = $manager->generate(1); $codes = $manager->generate(1);
$code = $codes[0]; $code = $codes[0];
$item = $pool->getItem('backup_' . strtolower($code)); $item = $pool->getItem('backup_'.strtolower($code));
$expiry = $item->getMetadata()['expiry']; $expiry = $item->getMetadata()['expiry'];
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
} }
public function testVerifyAndConsumeValidCode(): void public function test_verify_and_consume_valid_code(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
$codes = $manager->generate(2); $codes = $manager->generate(2);
@@ -95,7 +97,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue($manager->verifyAndConsume($code)); self::assertTrue($manager->verifyAndConsume($code));
} }
public function testVerifyAndConsumeMarksCodeAsUsed(): void public function test_verify_and_consume_marks_code_as_used(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -109,14 +111,14 @@ final class BackupCodeManagerTest extends TestCase
self::assertFalse($manager->verifyAndConsume($code)); self::assertFalse($manager->verifyAndConsume($code));
} }
public function testVerifyAndConsumeInvalidCode(): void public function test_verify_and_consume_invalid_code(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
self::assertFalse($manager->verifyAndConsume('nonexistent_code')); self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
} }
public function testVerifyAndConsumeIsCaseInsensitive(): void public function test_verify_and_consume_is_case_insensitive(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
$codes = $manager->generate(1); $codes = $manager->generate(1);
@@ -126,17 +128,17 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue($manager->verifyAndConsume(strtoupper($code))); self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
} }
public function testVerifyAndConsumeStripsInvalidCharacters(): void public function test_verify_and_consume_strips_invalid_characters(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
$codes = $manager->generate(1); $codes = $manager->generate(1);
$code = $codes[0]; $code = $codes[0];
// inject spaces and special chars — should be stripped // inject spaces and special chars — should be stripped
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!')); self::assertTrue($manager->verifyAndConsume(' '.$code.'!!'));
} }
public function testExpireRemovesAllBackupCodes(): void public function test_expire_removes_all_backup_codes(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -146,11 +148,11 @@ final class BackupCodeManagerTest extends TestCase
// all backup keys should be gone // all backup keys should be gone
foreach ($codes as $code) { foreach ($codes as $code) {
self::assertFalse($pool->hasItem('backup_' . strtolower($code))); self::assertFalse($pool->hasItem('backup_'.strtolower($code)));
} }
} }
public function testExpireWhenNoBackupCodesIsNoop(): void public function test_expire_when_no_backup_codes_is_noop(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -162,7 +164,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue(true); self::assertTrue(true);
} }
public function testExpireRemovesOnlyBackupPrefixedKeys(): void public function test_expire_removes_only_backup_prefixed_keys(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -181,11 +183,11 @@ final class BackupCodeManagerTest extends TestCase
// backup keys are gone // backup keys are gone
foreach ($codes as $code) { foreach ($codes as $code) {
self::assertFalse($pool->hasItem('backup_' . strtolower($code))); self::assertFalse($pool->hasItem('backup_'.strtolower($code)));
} }
} }
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void public function test_verify_and_consume_empty_string_returns_false(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
@@ -193,7 +195,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertFalse($manager->verifyAndConsume('')); self::assertFalse($manager->verifyAndConsume(''));
} }
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void public function test_verify_and_consume_code_with_value_false_returns_false(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$manager = $this->makeManager($pool); $manager = $this->makeManager($pool);
@@ -204,7 +206,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertTrue($manager->verifyAndConsume($code)); self::assertTrue($manager->verifyAndConsume($code));
// the code is now marked as false (used); isHit is true but get() is false // the code is now marked as false (used); isHit is true but get() is false
$key = 'backup_' . strtolower($code); $key = 'backup_'.strtolower($code);
$item = $pool->getItem($key); $item = $pool->getItem($key);
self::assertTrue($item->isHit()); self::assertTrue($item->isHit());
self::assertFalse($item->get()); self::assertFalse($item->get());
@@ -213,7 +215,7 @@ final class BackupCodeManagerTest extends TestCase
self::assertFalse($manager->verifyAndConsume($code)); self::assertFalse($manager->verifyAndConsume($code));
} }
public function testGenerateProducesUniqueCodes(): void public function test_generate_produces_unique_codes(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
@@ -223,13 +225,13 @@ final class BackupCodeManagerTest extends TestCase
self::assertCount(50, array_unique($codes), 'All generated codes should be unique'); self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
} }
public function testGenerateCodeLengthIsDigitsPlusTwo(): void public function test_generate_code_length_is_digits_plus_two(): void
{ {
$manager = $this->makeManager(); $manager = $this->makeManager();
$codes = $manager->generate(1); $codes = $manager->generate(1);
// default TOTP digits is 6, so code length should be 6 + 2 = 8 // default TOTP digits is 6, so code length should be 6 + 2 = 8
self::assertSame(8, strlen($codes[0])); self::assertSame(8, \strlen($codes[0]));
} }
} }
+34 -34
View File
@@ -16,42 +16,42 @@ final class DomainManagerTest extends TestCase
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */ /* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void public function test_auth_base_is_null_when_subdomain_redirect_is_disabled(): void
{ {
$manager = $this->createManager(false, 'auth.example.com'); $manager = $this->createManager(false, 'auth.example.com');
self::assertNull($manager->authBase()); self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain()); self::assertNull($manager->getAuthSubdomain());
} }
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void public function test_auth_base_is_null_when_auth_subdomain_is_empty(): void
{ {
$manager = $this->createManager(true, ''); $manager = $this->createManager(true, '');
self::assertNull($manager->authBase()); self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain()); self::assertNull($manager->getAuthSubdomain());
} }
public function testAuthBaseExtractsSimpleDomain(): void public function test_auth_base_extracts_simple_domain(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase()); self::assertSame('example.com', $manager->authBase());
self::assertSame('auth.example.com', $manager->getAuthSubdomain()); self::assertSame('auth.example.com', $manager->getAuthSubdomain());
} }
public function testAuthBaseExtractsMultiPartTld(): void public function test_auth_base_extracts_multi_part_tld(): void
{ {
$manager = $this->createManager(true, 'auth.example.co.uk'); $manager = $this->createManager(true, 'auth.example.co.uk');
self::assertSame('example.co.uk', $manager->authBase()); self::assertSame('example.co.uk', $manager->authBase());
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain()); self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
} }
public function testAuthBaseIsNullForLocalhostAuth(): void public function test_auth_base_is_null_for_localhost_auth(): void
{ {
$manager = $this->createManager(true, 'localhost'); $manager = $this->createManager(true, 'localhost');
self::assertNull($manager->authBase()); self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain()); self::assertNull($manager->getAuthSubdomain());
} }
public function testAuthBaseIsNullForIpAuth(): void public function test_auth_base_is_null_for_ip_auth(): void
{ {
$manager = $this->createManager(true, '192.168.1.1'); $manager = $this->createManager(true, '192.168.1.1');
self::assertNull($manager->authBase()); self::assertNull($manager->authBase());
@@ -60,42 +60,42 @@ final class DomainManagerTest extends TestCase
/* ── validReturn ──────────────────────────────────────────────────────── */ /* ── validReturn ──────────────────────────────────────────────────────── */
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void public function test_valid_return_accepts_any_url_when_no_subdomain(): void
{ {
$manager = $this->createManager(false, ''); $manager = $this->createManager(false, '');
self::assertTrue($manager->validReturn('https://evil.com/page')); self::assertTrue($manager->validReturn('https://evil.com/page'));
self::assertTrue($manager->validReturn('https://example.com/ok')); self::assertTrue($manager->validReturn('https://example.com/ok'));
} }
public function testValidReturnRejectsInvalidUrl(): void public function test_valid_return_rejects_invalid_url(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('not-a-url')); self::assertFalse($manager->validReturn('not-a-url'));
self::assertFalse($manager->validReturn('')); self::assertFalse($manager->validReturn(''));
} }
public function testValidReturnAcceptsSameBaseDomain(): void public function test_valid_return_accepts_same_base_domain(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://app.example.com/dashboard')); self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
self::assertTrue($manager->validReturn('https://example.com/')); self::assertTrue($manager->validReturn('https://example.com/'));
} }
public function testValidReturnRejectsDifferentBaseDomain(): void public function test_valid_return_rejects_different_base_domain(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com/phish')); self::assertFalse($manager->validReturn('https://evil.com/phish'));
self::assertFalse($manager->validReturn('https://other-example.com/')); self::assertFalse($manager->validReturn('https://other-example.com/'));
} }
public function testValidReturnHandlesCoUkTld(): void public function test_valid_return_handles_co_uk_tld(): void
{ {
$manager = $this->createManager(true, 'auth.example.co.uk'); $manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->validReturn('https://www.example.co.uk/')); self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
self::assertFalse($manager->validReturn('https://example.com/')); self::assertFalse($manager->validReturn('https://example.com/'));
} }
public function testValidReturnRejectsUrlWithoutHost(): void public function test_valid_return_rejects_url_without_host(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('mailto:test@example.com')); self::assertFalse($manager->validReturn('mailto:test@example.com'));
@@ -103,47 +103,47 @@ final class DomainManagerTest extends TestCase
/* ── matchesAuth ──────────────────────────────────────────────────────── */ /* ── matchesAuth ──────────────────────────────────────────────────────── */
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void public function test_matches_auth_is_false_when_subdomain_redirect_disabled(): void
{ {
$manager = $this->createManager(false, 'auth.example.com'); $manager = $this->createManager(false, 'auth.example.com');
self::assertFalse($manager->matchesAuth('example.com')); self::assertFalse($manager->matchesAuth('example.com'));
self::assertFalse($manager->matchesAuth('app.example.com')); self::assertFalse($manager->matchesAuth('app.example.com'));
} }
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void public function test_matches_auth_is_false_when_auth_subdomain_is_empty(): void
{ {
$manager = $this->createManager(true, ''); $manager = $this->createManager(true, '');
self::assertFalse($manager->matchesAuth('example.com')); self::assertFalse($manager->matchesAuth('example.com'));
} }
public function testMatchesAuthMatchesSameBaseDomain(): void public function test_matches_auth_matches_same_base_domain(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('example.com')); self::assertTrue($manager->matchesAuth('example.com'));
self::assertTrue($manager->matchesAuth('app.example.com')); self::assertTrue($manager->matchesAuth('app.example.com'));
} }
public function testMatchesAuthRejectsDifferentBaseDomain(): void public function test_matches_auth_rejects_different_base_domain(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('evil.com')); self::assertFalse($manager->matchesAuth('evil.com'));
self::assertFalse($manager->matchesAuth('example.org')); self::assertFalse($manager->matchesAuth('example.org'));
} }
public function testMatchesAuthHandlesMultiPartTld(): void public function test_matches_auth_handles_multi_part_tld(): void
{ {
$manager = $this->createManager(true, 'auth.example.co.uk'); $manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->matchesAuth('www.example.co.uk')); self::assertTrue($manager->matchesAuth('www.example.co.uk'));
self::assertFalse($manager->matchesAuth('example.com')); self::assertFalse($manager->matchesAuth('example.com'));
} }
public function testMatchesAuthRejectsIpHost(): void public function test_matches_auth_rejects_ip_host(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('192.168.1.1')); self::assertFalse($manager->matchesAuth('192.168.1.1'));
} }
public function testMatchesAuthRejectsLocalhost(): void public function test_matches_auth_rejects_localhost(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('localhost')); self::assertFalse($manager->matchesAuth('localhost'));
@@ -151,13 +151,13 @@ final class DomainManagerTest extends TestCase
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */ /* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
public function testMatchesAuthWithDeepSubdomain(): void public function test_matches_auth_with_deep_subdomain(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('a.b.c.example.com')); self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
} }
public function testMatchesAuthWithTwoPartDomain(): void public function test_matches_auth_with_two_part_domain(): void
{ {
/* for a 2-part auth subdomain, the baseDomain retains both parts */ /* for a 2-part auth subdomain, the baseDomain retains both parts */
$manager = $this->createManager(true, 'auth.local'); $manager = $this->createManager(true, 'auth.local');
@@ -169,7 +169,7 @@ final class DomainManagerTest extends TestCase
/* ── TLD table coverage ──────────────────────────────────────────────── */ /* ── TLD table coverage ──────────────────────────────────────────────── */
public function testMatchesAuthWithComAuTld(): void public function test_matches_auth_with_com_au_tld(): void
{ {
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part // com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
$manager = $this->createManager(true, 'auth.example.com.au'); $manager = $this->createManager(true, 'auth.example.com.au');
@@ -178,7 +178,7 @@ final class DomainManagerTest extends TestCase
self::assertFalse($manager->matchesAuth('example.com')); self::assertFalse($manager->matchesAuth('example.com'));
} }
public function testMatchesAuthWithCoJpTld(): void public function test_matches_auth_with_co_jp_tld(): void
{ {
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part // co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
$manager = $this->createManager(true, 'auth.example.co.jp'); $manager = $this->createManager(true, 'auth.example.co.jp');
@@ -186,7 +186,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('www.example.co.jp')); self::assertTrue($manager->matchesAuth('www.example.co.jp'));
} }
public function testMatchesAuthWithComBrTld(): void public function test_matches_auth_with_com_br_tld(): void
{ {
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part // com.br: TLD table has br => [com,...], so *.com.br IS multi-part
$manager = $this->createManager(true, 'auth.example.com.br'); $manager = $this->createManager(true, 'auth.example.com.br');
@@ -194,7 +194,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.com.br')); self::assertTrue($manager->matchesAuth('app.example.com.br'));
} }
public function testMatchesAuthWithCoNzTld(): void public function test_matches_auth_with_co_nz_tld(): void
{ {
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part) // co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
$manager = $this->createManager(true, 'auth.example.co.nz'); $manager = $this->createManager(true, 'auth.example.co.nz');
@@ -202,7 +202,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('sub.example.co.nz')); self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
} }
public function testMatchesAuthWithComMxTld(): void public function test_matches_auth_with_com_mx_tld(): void
{ {
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part) // com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
$manager = $this->createManager(true, 'auth.example.com.mx'); $manager = $this->createManager(true, 'auth.example.com.mx');
@@ -210,7 +210,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.com.mx')); self::assertTrue($manager->matchesAuth('app.example.com.mx'));
} }
public function testMatchesAuthWithCoInTld(): void public function test_matches_auth_with_co_in_tld(): void
{ {
// co.in: in => [co,...], so *.co.in IS multi-part // co.in: in => [co,...], so *.co.in IS multi-part
$manager = $this->createManager(true, 'auth.example.co.in'); $manager = $this->createManager(true, 'auth.example.co.in');
@@ -218,7 +218,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.co.in')); self::assertTrue($manager->matchesAuth('app.example.co.in'));
} }
public function testMatchesAuthWithBrComTld(): void public function test_matches_auth_with_br_com_tld(): void
{ {
// br.com: TLD table has com => [br], so *.br.com IS multi-part // br.com: TLD table has com => [br], so *.br.com IS multi-part
$manager = $this->createManager(true, 'auth.example.br.com'); $manager = $this->createManager(true, 'auth.example.br.com');
@@ -226,7 +226,7 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.example.br.com')); self::assertTrue($manager->matchesAuth('app.example.br.com'));
} }
public function testSimpleTldNotTreatedAsMultiPart(): void public function test_simple_tld_not_treated_as_multi_part(): void
{ {
// example.com is a standard 2-part domain, not multi-part // example.com is a standard 2-part domain, not multi-part
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
@@ -237,7 +237,7 @@ final class DomainManagerTest extends TestCase
/* ── baseDomain edge cases ───────────────────────────────────────────── */ /* ── baseDomain edge cases ───────────────────────────────────────────── */
public function testMatchesAuthWithSingleLabelHost(): void public function test_matches_auth_with_single_label_host(): void
{ {
// a single-label domain (not localhost, not IP) has baseLength 1 // a single-label domain (not localhost, not IP) has baseLength 1
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local' // so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
@@ -249,25 +249,25 @@ final class DomainManagerTest extends TestCase
self::assertTrue($manager->matchesAuth('app.auth.local')); self::assertTrue($manager->matchesAuth('app.auth.local'));
} }
public function testMatchesAuthWithEmptyStringHost(): void public function test_matches_auth_with_empty_string_host(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('')); self::assertFalse($manager->matchesAuth(''));
} }
public function testValidReturnAcceptsUrlWithPort(): void public function test_valid_return_accepts_url_with_port(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com:8080/path')); self::assertTrue($manager->validReturn('https://example.com:8080/path'));
} }
public function testValidReturnAcceptsUrlWithoutPath(): void public function test_valid_return_accepts_url_without_path(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com')); self::assertTrue($manager->validReturn('https://example.com'));
} }
public function testValidReturnRejectsDifferentDomainWithPort(): void public function test_valid_return_rejects_different_domain_with_port(): void
{ {
$manager = $this->createManager(true, 'auth.example.com'); $manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com:8080/path')); self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
+37 -31
View File
@@ -8,21 +8,22 @@ use App\Data\Payload;
use App\Enum\Scope; use App\Enum\Scope;
use App\Service\BackupCodeInterface; use App\Service\BackupCodeInterface;
use App\Service\DomainManager; use App\Service\DomainManager;
use App\Trait\StringTrait;
use App\Service\LoginManager; use App\Service\LoginManager;
use App\Tests\Support\TotpTestHelper; use App\Tests\Support\TotpTestHelper;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use ReflectionProperty;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
final class LoginManagerTest extends TestCase final class LoginManagerTest extends TestCase
{ {
use TotpTestHelper;
use StringTrait; use StringTrait;
use TotpTestHelper;
private ArrayAdapter $pool; private ArrayAdapter $pool;
private BackupCodeInterface $backupCodeManager; private BackupCodeInterface $backupCodeManager;
@@ -41,6 +42,7 @@ final class LoginManagerTest extends TestCase
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl)); $manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
$manager->setLogger(new NullLogger()); $manager->setLogger(new NullLogger());
$manager->setNonceCache(new ArrayAdapter()); $manager->setNonceCache(new ArrayAdapter());
return $manager; return $manager;
} }
@@ -60,13 +62,14 @@ final class LoginManagerTest extends TestCase
$payload->nonce = $nonce; $payload->nonce = $nonce;
$payload->json = true; $payload->json = true;
$payload->scope = $scope; $payload->scope = $scope;
return $payload; return $payload;
} }
/** Inject a nonce directly into the manager's nonce cache. */ /** Inject a nonce directly into the manager's nonce cache. */
private function insertNonce(LoginManager $manager, string $nonce): string private function insertNonce(LoginManager $manager, string $nonce): string
{ {
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache'); $reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager); $nonceCache = $reflection->getValue($manager);
$key = $this->makeCacheKey($nonce); $key = $this->makeCacheKey($nonce);
@@ -77,7 +80,7 @@ final class LoginManagerTest extends TestCase
return $nonce; return $nonce;
} }
public function testCheckTokenReturnsNullForInvalidTotp(): void public function test_check_token_returns_null_for_invalid_totp(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code'); $payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
@@ -89,7 +92,7 @@ final class LoginManagerTest extends TestCase
self::assertNull($manager->checkToken($payload, $request)); self::assertNull($manager->checkToken($payload, $request));
} }
public function testCheckTokenReturnsNullForSpentNonce(): void public function test_check_token_returns_null_for_spent_nonce(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager); $payload = $this->makePayloadWithNonce($manager);
@@ -97,7 +100,7 @@ final class LoginManagerTest extends TestCase
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false); $this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
// spend the nonce first (use the same cache key the manager does) // spend the nonce first (use the same cache key the manager does)
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache'); $reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager); $nonceCache = $reflection->getValue($manager);
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123')); $nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
$nonceItem->set(false); $nonceItem->set(false);
@@ -108,7 +111,7 @@ final class LoginManagerTest extends TestCase
self::assertNull($manager->checkToken($payload, $request)); self::assertNull($manager->checkToken($payload, $request));
} }
public function testCheckTokenReturnsNullForMissingNonce(): void public function test_check_token_returns_null_for_missing_nonce(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
@@ -126,7 +129,7 @@ final class LoginManagerTest extends TestCase
self::assertNull($manager->checkToken($payload, $request)); self::assertNull($manager->checkToken($payload, $request));
} }
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void public function test_successful_totp_login_with_cookie_scope_returns_redirect(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -143,7 +146,7 @@ final class LoginManagerTest extends TestCase
self::assertTrue($response->headers->has('Set-Cookie')); self::assertTrue($response->headers->has('Set-Cookie'));
} }
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void public function test_successful_login_with_none_scope_returns_plain_response(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None); $payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
@@ -162,7 +165,7 @@ final class LoginManagerTest extends TestCase
self::assertFalse($response->headers->has('Location')); self::assertFalse($response->headers->has('Location'));
} }
public function testSuccessfulLoginSetsRemoteUserHeader(): void public function test_successful_login_sets_remote_user_header(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None); $payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
@@ -177,7 +180,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('alice', $response->headers->get('Remote-User')); self::assertSame('alice', $response->headers->get('Remote-User'));
} }
public function testSuccessfulLoginJsonResponse(): void public function test_successful_login_json_response(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
@@ -195,7 +198,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('Login successful', $body['message']); self::assertSame('Login successful', $body['message']);
} }
public function testSuccessfulLoginHtmlResponse(): void public function test_successful_login_html_response(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -211,7 +214,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('text/html', $response->headers->get('Content-Type')); self::assertSame('text/html', $response->headers->get('Content-Type'));
} }
public function testSuccessfulLoginWithReturnUrl(): void public function test_successful_login_with_return_url(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -226,7 +229,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('https://example.com/app', $response->headers->get('Location')); 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
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -242,7 +245,7 @@ final class LoginManagerTest extends TestCase
self::assertStringStartsWith('/login', $location); self::assertStringStartsWith('/login', $location);
} }
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void public function test_ip_scope_downgrades_to_cookie_when_ip_access_disabled(): void
{ {
$manager = $this->makeLoginManager(ipTtl: 0); $manager = $this->makeLoginManager(ipTtl: 0);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -258,7 +261,7 @@ final class LoginManagerTest extends TestCase
self::assertTrue($response->headers->has('Set-Cookie')); self::assertTrue($response->headers->has('Set-Cookie'));
} }
public function testIpScopeWhenEnabledSetsIpSession(): void public function test_ip_scope_when_enabled_sets_ip_session(): void
{ {
$manager = $this->makeLoginManager(ipTtl: 1800); $manager = $this->makeLoginManager(ipTtl: 1800);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -274,12 +277,12 @@ final class LoginManagerTest extends TestCase
self::assertFalse($response->headers->has('Set-Cookie')); self::assertFalse($response->headers->has('Set-Cookie'));
// verify the IP session exists in the cache // verify the IP session exists in the cache
$reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache'); $reflection = new ReflectionProperty(LoginManager::class, 'sessionCache');
$sessionCache = $reflection->getValue($manager); $sessionCache = $reflection->getValue($manager);
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4')); self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
} }
public function testBackupCodeAuthentication(): void public function test_backup_code_authentication(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123'); $payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
@@ -294,7 +297,7 @@ final class LoginManagerTest extends TestCase
self::assertSame(303, $response->getStatusCode()); self::assertSame(303, $response->getStatusCode());
} }
public function testNonceIsConsumedAfterSuccessfulLogin(): void public function test_nonce_is_consumed_after_successful_login(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager); $payload = $this->makePayloadWithNonce($manager);
@@ -307,13 +310,13 @@ final class LoginManagerTest extends TestCase
// nonce should now be marked invalid (false); look it up via the same // nonce should now be marked invalid (false); look it up via the same
// cache key the manager uses (makeCacheKey rewrites '-' to '_') // cache key the manager uses (makeCacheKey rewrites '-' to '_')
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache'); $reflection = new ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager); $nonceCache = $reflection->getValue($manager);
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123')); $nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
self::assertFalse($nonceItem->get()); self::assertFalse($nonceItem->get());
} }
public function testUlidCollisionThrowsHttpException(): void public function test_ulid_collision_throws_http_exception(): void
{ {
// Use a stub pool where every cookie_ key is already a hit (collision) // Use a stub pool where every cookie_ key is already a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class); $pool = $this->createStub(CacheItemPoolInterface::class);
@@ -322,29 +325,32 @@ final class LoginManagerTest extends TestCase
$item->method('get')->willReturn('existing'); $item->method('get')->willReturn('existing');
// The nonce cache needs to work, so we return the stub item for // The nonce cache needs to work, so we return the stub item for
// cookie_ keys but a real working item for nonce keys. // cookie_ keys but a real working item for nonce keys.
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) { $pool->method('getItem')->willReturnCallback(static function (string $key) use ($item) {
if (str_starts_with($key, 'cookie_')) { if (str_starts_with($key, 'cookie_')) {
return $item; // collision return $item; // collision
} }
// For nonce keys, return a real item from an ArrayAdapter // For nonce keys, return a real item from an ArrayAdapter
static $realPool = null; static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter(); $realPool ??= new ArrayAdapter();
return $realPool->getItem($key); return $realPool->getItem($key);
}); });
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) { $pool->method('hasItem')->willReturnCallback(static function (string $key) {
if (str_starts_with($key, 'cookie_')) { if (str_starts_with($key, 'cookie_')) {
return true; return true;
} }
static $realPool = null; static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter(); $realPool ??= new ArrayAdapter();
return $realPool->hasItem($key); return $realPool->hasItem($key);
}); });
$pool->method('save')->willReturn(true); $pool->method('save')->willReturn(true);
$pool->method('saveDeferred')->willReturn(true); $pool->method('saveDeferred')->willReturn(true);
$pool->method('commit')->willReturn(true); $pool->method('commit')->willReturn(true);
$pool->method('getItems')->willReturnCallback(function (array $keys) { $pool->method('getItems')->willReturnCallback(static function (array $keys) {
static $realPool = null; static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter(); $realPool ??= new ArrayAdapter();
return $realPool->getItems($keys); return $realPool->getItems($keys);
}); });
$pool->method('clear')->willReturn(true); $pool->method('clear')->willReturn(true);
@@ -358,7 +364,7 @@ final class LoginManagerTest extends TestCase
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager); $manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
$manager->setConfig($this->makeConfig()); $manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger()); $manager->setLogger(new NullLogger());
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter()); $manager->setNonceCache(new ArrayAdapter());
$payload = new Payload(); $payload = new Payload();
$payload->id = 'collide-user'; $payload->id = 'collide-user';
@@ -376,7 +382,7 @@ final class LoginManagerTest extends TestCase
$manager->checkToken($payload, $request); $manager->checkToken($payload, $request);
} }
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void public function test_cookie_scope_with_central_auth_sets_domain_on_matching_host(): void
{ {
$manager = $this->makeLoginManager( $manager = $this->makeLoginManager(
subdomainRedirect: true, subdomainRedirect: true,
@@ -400,7 +406,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
} }
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void public function test_cookie_scope_with_central_auth_on_non_matching_host_uses_null_domain(): void
{ {
$manager = $this->makeLoginManager( $manager = $this->makeLoginManager(
subdomainRedirect: true, subdomainRedirect: true,
@@ -424,7 +430,7 @@ final class LoginManagerTest extends TestCase
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
} }
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void public function test_check_token_with_empty_return_parameter_falls_back_to_path(): void
{ {
$manager = $this->makeLoginManager(); $manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
+31 -31
View File
@@ -16,14 +16,14 @@ final class PublicPathMatcherTest extends TestCase
{ {
/* ── empty / disabled ──────────────────────────────────────────────── */ /* ── empty / disabled ──────────────────────────────────────────────── */
public function testEmptyStringResultsInNoPatterns(): void public function test_empty_string_results_in_no_patterns(): void
{ {
$matcher = new PublicPathMatcher(''); $matcher = new PublicPathMatcher('');
self::assertTrue($matcher->isEmpty()); self::assertTrue($matcher->isEmpty());
self::assertFalse($matcher->matches('example.com', '/public')); self::assertFalse($matcher->matches('example.com', '/public'));
} }
public function testWhitespaceOnlyStringResultsInNoPatterns(): void public function test_whitespace_only_string_results_in_no_patterns(): void
{ {
$matcher = new PublicPathMatcher(' '); $matcher = new PublicPathMatcher(' ');
self::assertTrue($matcher->isEmpty()); self::assertTrue($matcher->isEmpty());
@@ -31,20 +31,20 @@ final class PublicPathMatcherTest extends TestCase
/* ── exact path matching ───────────────────────────────────────────── */ /* ── exact path matching ───────────────────────────────────────────── */
public function testExactPathMatch(): void public function test_exact_path_match(): void
{ {
$matcher = new PublicPathMatcher('/public'); $matcher = new PublicPathMatcher('/public');
self::assertTrue($matcher->matches('example.com', '/public')); self::assertTrue($matcher->matches('example.com', '/public'));
} }
public function testExactPathDoesNotMatchSubpath(): void public function test_exact_path_does_not_match_subpath(): void
{ {
$matcher = new PublicPathMatcher('/public'); $matcher = new PublicPathMatcher('/public');
self::assertFalse($matcher->matches('example.com', '/public/')); self::assertFalse($matcher->matches('example.com', '/public/'));
self::assertFalse($matcher->matches('example.com', '/public/repo')); self::assertFalse($matcher->matches('example.com', '/public/repo'));
} }
public function testExactPathDoesNotMatchDifferentPath(): void public function test_exact_path_does_not_match_different_path(): void
{ {
$matcher = new PublicPathMatcher('/public'); $matcher = new PublicPathMatcher('/public');
self::assertFalse($matcher->matches('example.com', '/private')); self::assertFalse($matcher->matches('example.com', '/private'));
@@ -53,26 +53,26 @@ final class PublicPathMatcherTest extends TestCase
/* ── single wildcard * ─────────────────────────────────────────────── */ /* ── single wildcard * ─────────────────────────────────────────────── */
public function testSingleWildcardMatchesOneSegment(): void public function test_single_wildcard_matches_one_segment(): void
{ {
$matcher = new PublicPathMatcher('/public/*'); $matcher = new PublicPathMatcher('/public/*');
self::assertTrue($matcher->matches('example.com', '/public/repo')); self::assertTrue($matcher->matches('example.com', '/public/repo'));
self::assertTrue($matcher->matches('example.com', '/public/xyz')); self::assertTrue($matcher->matches('example.com', '/public/xyz'));
} }
public function testSingleWildcardDoesNotMatchBasePath(): void public function test_single_wildcard_does_not_match_base_path(): void
{ {
$matcher = new PublicPathMatcher('/public/*'); $matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public')); self::assertFalse($matcher->matches('example.com', '/public'));
} }
public function testSingleWildcardDoesNotCrossSegments(): void public function test_single_wildcard_does_not_cross_segments(): void
{ {
$matcher = new PublicPathMatcher('/public/*'); $matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public/a/b')); self::assertFalse($matcher->matches('example.com', '/public/a/b'));
} }
public function testSingleWildcardDoesNotMatchEmptySegment(): void public function test_single_wildcard_does_not_match_empty_segment(): void
{ {
$matcher = new PublicPathMatcher('/public/*'); $matcher = new PublicPathMatcher('/public/*');
self::assertFalse($matcher->matches('example.com', '/public/')); self::assertFalse($matcher->matches('example.com', '/public/'));
@@ -80,20 +80,20 @@ final class PublicPathMatcherTest extends TestCase
/* ── double wildcard ** ────────────────────────────────────────────── */ /* ── double wildcard ** ────────────────────────────────────────────── */
public function testDoubleWildcardMatchesMultipleSegments(): void public function test_double_wildcard_matches_multiple_segments(): void
{ {
$matcher = new PublicPathMatcher('/public/**'); $matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('example.com', '/public/a')); self::assertTrue($matcher->matches('example.com', '/public/a'));
self::assertTrue($matcher->matches('example.com', '/public/a/b/c')); self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
} }
public function testDoubleWildcardDoesNotMatchBasePath(): void public function test_double_wildcard_does_not_match_base_path(): void
{ {
$matcher = new PublicPathMatcher('/public/**'); $matcher = new PublicPathMatcher('/public/**');
self::assertFalse($matcher->matches('example.com', '/public')); self::assertFalse($matcher->matches('example.com', '/public'));
} }
public function testDoubleWildcardMatchesTrailingSlash(): void public function test_double_wildcard_matches_trailing_slash(): void
{ {
$matcher = new PublicPathMatcher('/public/**'); $matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('example.com', '/public/')); self::assertTrue($matcher->matches('example.com', '/public/'));
@@ -101,7 +101,7 @@ final class PublicPathMatcherTest extends TestCase
/* ── mid-path wildcards ────────────────────────────────────────────── */ /* ── mid-path wildcards ────────────────────────────────────────────── */
public function testMidPathSingleWildcard(): void public function test_mid_path_single_wildcard(): void
{ {
$matcher = new PublicPathMatcher('/api/*/status'); $matcher = new PublicPathMatcher('/api/*/status');
self::assertTrue($matcher->matches('example.com', '/api/v1/status')); self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
@@ -110,7 +110,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('example.com', '/api/status')); self::assertFalse($matcher->matches('example.com', '/api/status'));
} }
public function testMidPathDoubleWildcard(): void public function test_mid_path_double_wildcard(): void
{ {
$matcher = new PublicPathMatcher('/api/**/status'); $matcher = new PublicPathMatcher('/api/**/status');
self::assertTrue($matcher->matches('example.com', '/api/v1/status')); self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
@@ -120,7 +120,7 @@ final class PublicPathMatcherTest extends TestCase
/* ── multiple patterns ─────────────────────────────────────────────── */ /* ── multiple patterns ─────────────────────────────────────────────── */
public function testMultiplePatternsCommaSeparated(): void public function test_multiple_patterns_comma_separated(): void
{ {
$matcher = new PublicPathMatcher('/public/**,/api/status,/health'); $matcher = new PublicPathMatcher('/public/**,/api/status,/health');
self::assertTrue($matcher->matches('example.com', '/public/repo')); self::assertTrue($matcher->matches('example.com', '/public/repo'));
@@ -129,7 +129,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('example.com', '/private')); self::assertFalse($matcher->matches('example.com', '/private'));
} }
public function testMultiplePatternsWithWhitespace(): void public function test_multiple_patterns_with_whitespace(): void
{ {
$matcher = new PublicPathMatcher('/public/**, /api/status, /health'); $matcher = new PublicPathMatcher('/public/**, /api/status, /health');
self::assertTrue($matcher->matches('example.com', '/public/repo')); self::assertTrue($matcher->matches('example.com', '/public/repo'));
@@ -137,7 +137,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertTrue($matcher->matches('example.com', '/health')); self::assertTrue($matcher->matches('example.com', '/health'));
} }
public function testEmptySegmentsInCommaListAreIgnored(): void public function test_empty_segments_in_comma_list_are_ignored(): void
{ {
$matcher = new PublicPathMatcher('/public,,/health,'); $matcher = new PublicPathMatcher('/public,,/health,');
self::assertFalse($matcher->isEmpty()); self::assertFalse($matcher->isEmpty());
@@ -147,20 +147,20 @@ final class PublicPathMatcherTest extends TestCase
/* ── domain-prefixed patterns ──────────────────────────────────────── */ /* ── domain-prefixed patterns ──────────────────────────────────────── */
public function testDomainPrefixedPatternMatchesOnThatHost(): void public function test_domain_prefixed_pattern_matches_on_that_host(): void
{ {
$matcher = new PublicPathMatcher('code.example.com/public/**'); $matcher = new PublicPathMatcher('code.example.com/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo')); self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
} }
public function testDomainPrefixedPatternDoesNotMatchOtherHost(): void public function test_domain_prefixed_pattern_does_not_match_other_host(): void
{ {
$matcher = new PublicPathMatcher('code.example.com/public/**'); $matcher = new PublicPathMatcher('code.example.com/public/**');
self::assertFalse($matcher->matches('other.example.com', '/public/repo')); self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
self::assertFalse($matcher->matches('example.com', '/public/repo')); self::assertFalse($matcher->matches('example.com', '/public/repo'));
} }
public function testPathWithoutDomainPrefixMatchesAnyHost(): void public function test_path_without_domain_prefix_matches_any_host(): void
{ {
$matcher = new PublicPathMatcher('/public/**'); $matcher = new PublicPathMatcher('/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo')); self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
@@ -168,7 +168,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertTrue($matcher->matches('localhost', '/public/repo')); self::assertTrue($matcher->matches('localhost', '/public/repo'));
} }
public function testMixedDomainPrefixedAndPlainPatterns(): void public function test_mixed_domain_prefixed_and_plain_patterns(): void
{ {
$matcher = new PublicPathMatcher('/health,code.example.com/public/**'); $matcher = new PublicPathMatcher('/health,code.example.com/public/**');
self::assertTrue($matcher->matches('any.host', '/health')); self::assertTrue($matcher->matches('any.host', '/health'));
@@ -176,7 +176,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.host', '/public/repo')); self::assertFalse($matcher->matches('other.host', '/public/repo'));
} }
public function testDomainPrefixedRootPathMatchesRoot(): void public function test_domain_prefixed_root_path_matches_root(): void
{ {
// host/ — the trailing slash is the entire path, nothing after it // host/ — the trailing slash is the entire path, nothing after it
$matcher = new PublicPathMatcher('code.example.com/'); $matcher = new PublicPathMatcher('code.example.com/');
@@ -185,7 +185,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.example.com', '/')); self::assertFalse($matcher->matches('other.example.com', '/'));
} }
public function testDomainPrefixedRootWithOtherPatterns(): void public function test_domain_prefixed_root_with_other_patterns(): void
{ {
// The exact scenario from the bug report // The exact scenario from the bug report
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**'); $matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
@@ -195,7 +195,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.example.com', '/')); self::assertFalse($matcher->matches('other.example.com', '/'));
} }
public function testDomainPrefixIsCaseInsensitive(): void public function test_domain_prefix_is_case_insensitive(): void
{ {
$matcher = new PublicPathMatcher('Code.Example.COM/public/**'); $matcher = new PublicPathMatcher('Code.Example.COM/public/**');
self::assertTrue($matcher->matches('code.example.com', '/public/repo')); self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
@@ -204,13 +204,13 @@ final class PublicPathMatcherTest extends TestCase
/* ── invalid patterns ──────────────────────────────────────────────── */ /* ── invalid patterns ──────────────────────────────────────────────── */
public function testPatternWithoutLeadingSlashIsIgnored(): void public function test_pattern_without_leading_slash_is_ignored(): void
{ {
$matcher = new PublicPathMatcher('public'); $matcher = new PublicPathMatcher('public');
self::assertTrue($matcher->isEmpty()); self::assertTrue($matcher->isEmpty());
} }
public function testInvalidPatternAmongValidOnesIsIgnored(): void public function test_invalid_pattern_among_valid_ones_is_ignored(): void
{ {
$matcher = new PublicPathMatcher('invalid,/public'); $matcher = new PublicPathMatcher('invalid,/public');
self::assertFalse($matcher->isEmpty()); self::assertFalse($matcher->isEmpty());
@@ -219,14 +219,14 @@ final class PublicPathMatcherTest extends TestCase
/* ── special regex characters in paths ─────────────────────────────── */ /* ── special regex characters in paths ─────────────────────────────── */
public function testSpecialRegexCharactersAreEscaped(): void public function test_special_regex_characters_are_escaped(): void
{ {
$matcher = new PublicPathMatcher('/path.with.dots'); $matcher = new PublicPathMatcher('/path.with.dots');
self::assertTrue($matcher->matches('example.com', '/path.with.dots')); self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots')); self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
} }
public function testPlusCharacterIsLiteral(): void public function test_plus_character_is_literal(): void
{ {
$matcher = new PublicPathMatcher('/a+b'); $matcher = new PublicPathMatcher('/a+b');
self::assertTrue($matcher->matches('example.com', '/a+b')); self::assertTrue($matcher->matches('example.com', '/a+b'));
@@ -235,14 +235,14 @@ final class PublicPathMatcherTest extends TestCase
/* ── root path ─────────────────────────────────────────────────────── */ /* ── root path ─────────────────────────────────────────────────────── */
public function testRootPathMatch(): void public function test_root_path_match(): void
{ {
$matcher = new PublicPathMatcher('/'); $matcher = new PublicPathMatcher('/');
self::assertTrue($matcher->matches('example.com', '/')); self::assertTrue($matcher->matches('example.com', '/'));
self::assertFalse($matcher->matches('example.com', '/anything')); self::assertFalse($matcher->matches('example.com', '/anything'));
} }
public function testWildcardAtRoot(): void public function test_wildcard_at_root(): void
{ {
$matcher = new PublicPathMatcher('/*'); $matcher = new PublicPathMatcher('/*');
self::assertTrue($matcher->matches('example.com', '/anything')); self::assertTrue($matcher->matches('example.com', '/anything'));
@@ -250,7 +250,7 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('example.com', '/')); self::assertFalse($matcher->matches('example.com', '/'));
} }
public function testDoubleWildcardAtRoot(): void public function test_double_wildcard_at_root(): void
{ {
$matcher = new PublicPathMatcher('/**'); $matcher = new PublicPathMatcher('/**');
self::assertTrue($matcher->matches('example.com', '/')); self::assertTrue($matcher->matches('example.com', '/'));
+3 -3
View File
@@ -11,17 +11,17 @@ final class CookieNameTraitTest extends TestCase
{ {
use CookieNameTrait; use CookieNameTrait;
public function testCookieName(): void public function test_cookie_name(): void
{ {
self::assertSame('__Host-Http-Preauth', $this->cookieName()); self::assertSame('__Host-Http-Preauth', $this->cookieName());
} }
public function testAuthCookieName(): void public function test_auth_cookie_name(): void
{ {
self::assertSame('__Http-Domain-Preauth', $this->authCookieName()); self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
} }
public function testHeaderName(): void public function test_header_name(): void
{ {
self::assertSame('X-Preauth', $this->headerName()); self::assertSame('X-Preauth', $this->headerName());
} }
+10 -8
View File
@@ -9,7 +9,9 @@ use App\Tests\Support\TotpTestHelper;
use App\Trait\GetTotpTrait; use App\Trait\GetTotpTrait;
use OTPHP\TOTPInterface; use OTPHP\TOTPInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
final class GetTotpTraitTest extends TestCase final class GetTotpTraitTest extends TestCase
{ {
@@ -17,7 +19,7 @@ final class GetTotpTraitTest extends TestCase
private function makeObject(): object private function makeObject(): object
{ {
return new class () { return new class {
use GetTotpTrait; use GetTotpTrait;
public function publicGetTotp(): TOTPInterface public function publicGetTotp(): TOTPInterface
@@ -27,18 +29,18 @@ final class GetTotpTraitTest extends TestCase
}; };
} }
public function testSetConfigSetsProperty(): void public function test_set_config_sets_property(): void
{ {
$obj = $this->makeObject(); $obj = $this->makeObject();
$config = $this->makeConfig(); $config = $this->makeConfig();
$obj->setConfig($config); $obj->setConfig($config);
$reflection = new \ReflectionProperty($obj, 'config'); $reflection = new ReflectionProperty($obj, 'config');
self::assertSame($config, $reflection->getValue($obj)); self::assertSame($config, $reflection->getValue($obj));
} }
public function testGetTotpReturnsTotpInterface(): void public function test_get_totp_returns_totp_interface(): void
{ {
$obj = $this->makeObject(); $obj = $this->makeObject();
$obj->setConfig($this->makeConfig()); $obj->setConfig($this->makeConfig());
@@ -48,7 +50,7 @@ final class GetTotpTraitTest extends TestCase
self::assertInstanceOf(TOTPInterface::class, $totp); self::assertInstanceOf(TOTPInterface::class, $totp);
} }
public function testGetTotpReturnsValidCode(): void public function test_get_totp_returns_valid_code(): void
{ {
$obj = $this->makeObject(); $obj = $this->makeObject();
$obj->setConfig($this->makeConfig()); $obj->setConfig($this->makeConfig());
@@ -59,7 +61,7 @@ final class GetTotpTraitTest extends TestCase
self::assertSame($this->validTotpCode(), $totp->now()); self::assertSame($this->validTotpCode(), $totp->now());
} }
public function testGetTotpThrowsOnInvalidUri(): void public function test_get_totp_throws_on_invalid_uri(): void
{ {
$obj = $this->makeObject(); $obj = $this->makeObject();
$clock = $this->frozenClock(); $clock = $this->frozenClock();
@@ -83,11 +85,11 @@ final class GetTotpTraitTest extends TestCase
// Factory::loadFromProvisioningUri throws InvalidProvisioningUriException // Factory::loadFromProvisioningUri throws InvalidProvisioningUriException
// which is not caught by getTotp() since the instanceof check only runs // which is not caught by getTotp() since the instanceof check only runs
// after a successful load — so we expect a Throwable here // after a successful load — so we expect a Throwable here
$this->expectException(\Throwable::class); $this->expectException(Throwable::class);
$obj->publicGetTotp(); $obj->publicGetTotp();
} }
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void public function test_get_totp_throws_http_exception_when_not_totp_type(): void
{ {
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface, // A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
// so the instanceof check in getTotp() should throw an HttpException(500) // so the instanceof check in getTotp() should throw an HttpException(500)
+1 -1
View File
@@ -12,7 +12,7 @@ final class HasLoggerTraitTest extends TestCase
{ {
use HasLoggerTrait; use HasLoggerTrait;
public function testSetLogger(): void public function test_set_logger(): void
{ {
$logger = $this->createStub(LoggerInterface::class); $logger = $this->createStub(LoggerInterface::class);
$this->setLogger($logger); $this->setLogger($logger);
+36 -17
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Tests\Unit\Trait; namespace App\Tests\Unit\Trait;
use App\Trait\MakeNonceTrait; use App\Trait\MakeNonceTrait;
use DateInterval;
use DateTimeInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
@@ -20,7 +22,7 @@ final class MakeNonceTraitTest extends TestCase
{ {
private function makeObject(): object private function makeObject(): object
{ {
return new class () { return new class {
use MakeNonceTrait; use MakeNonceTrait;
public function publicMakeNonce(int $retries = 3): string public function publicMakeNonce(int $retries = 3): string
@@ -35,7 +37,7 @@ final class MakeNonceTraitTest extends TestCase
}; };
} }
public function testMakeNonceReturnsBase64UrlString(): void public function test_make_nonce_returns_base64_url_string(): void
{ {
$obj = $this->makeObject(); $obj = $this->makeObject();
$obj->setLogger(new NullLogger()); $obj->setLogger(new NullLogger());
@@ -45,12 +47,12 @@ final class MakeNonceTraitTest extends TestCase
self::assertIsString($nonce); self::assertIsString($nonce);
// 15 bytes -> 20 base64 chars without padding // 15 bytes -> 20 base64 chars without padding
self::assertSame(20, strlen($nonce)); self::assertSame(20, \strlen($nonce));
// base64url charset only // base64url charset only
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce); self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
} }
public function testMakeNonceStoresNonceInCache(): void public function test_make_nonce_stores_nonce_in_cache(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$obj = $this->makeObject(); $obj = $this->makeObject();
@@ -66,7 +68,7 @@ final class MakeNonceTraitTest extends TestCase
self::assertTrue($item->get()); self::assertTrue($item->get());
} }
public function testMakeNonceSetsExpiry(): void public function test_make_nonce_sets_expiry(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$obj = $this->makeObject(); $obj = $this->makeObject();
@@ -82,7 +84,7 @@ final class MakeNonceTraitTest extends TestCase
self::assertGreaterThan(time(), (int) $expiry); self::assertGreaterThan(time(), (int) $expiry);
} }
public function testTwoNoncesAreDifferent(): void public function test_two_nonces_are_different(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$obj = $this->makeObject(); $obj = $this->makeObject();
@@ -95,7 +97,7 @@ final class MakeNonceTraitTest extends TestCase
self::assertNotSame($nonce1, $nonce2); self::assertNotSame($nonce1, $nonce2);
} }
public function testMakeNonceThrowsAfterMaxRetries(): void public function test_make_nonce_throws_after_max_retries(): void
{ {
// Create a stub pool that always reports every key as a hit (collision) // Create a stub pool that always reports every key as a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class); $pool = $this->createStub(CacheItemPoolInterface::class);
@@ -115,7 +117,7 @@ final class MakeNonceTraitTest extends TestCase
$obj->publicMakeNonce(); $obj->publicMakeNonce();
} }
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void public function test_make_nonce_retries_and_succeeds_after_collision(): void
{ {
// Use a spy pool that returns isHit=true on the first getItem call // Use a spy pool that returns isHit=true on the first getItem call
// (simulating a collision), then delegates to a real ArrayAdapter for // (simulating a collision), then delegates to a real ArrayAdapter for
@@ -123,8 +125,9 @@ final class MakeNonceTraitTest extends TestCase
$realPool = new ArrayAdapter(); $realPool = new ArrayAdapter();
$collisionCount = 0; $collisionCount = 0;
$spyPool = new class ($realPool, $collisionCount) implements CacheItemPoolInterface { $spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
private int $hits = 0; private int $hits = 0;
public function __construct( public function __construct(
private CacheItemPoolInterface $inner, private CacheItemPoolInterface $inner,
private int &$hitCounter, private int &$hitCounter,
@@ -135,69 +138,85 @@ final class MakeNonceTraitTest extends TestCase
{ {
$item = $this->inner->getItem($key); $item = $this->inner->getItem($key);
// pretend the first requested key is already a hit (collision) // pretend the first requested key is already a hit (collision)
if ($this->hits === 0) { if (0 === $this->hits) {
$this->hits++; ++$this->hits;
$this->hitCounter++; ++$this->hitCounter;
return new class ($key) implements CacheItemInterface {
return new class($key) implements CacheItemInterface {
public function __construct(private string $key) public function __construct(private string $key)
{ {
} }
public function getKey(): string public function getKey(): string
{ {
return $this->key; return $this->key;
} }
public function get(): mixed public function get(): mixed
{ {
return true; return true;
} }
public function isHit(): bool public function isHit(): bool
{ {
return true; return true;
} }
public function set(mixed $value): static public function set(mixed $value): static
{ {
return $this; return $this;
} }
public function expiresAt(?\DateTimeInterface $expiration): static
public function expiresAt(?DateTimeInterface $expiration): static
{ {
return $this; return $this;
} }
public function expiresAfter(int|\DateInterval|null $time): static
public function expiresAfter(int|DateInterval|null $time): static
{ {
return $this; return $this;
} }
}; };
} }
return $item; return $item;
} }
public function getItems(array $keys = []): iterable public function getItems(array $keys = []): iterable
{ {
return $this->inner->getItems($keys); return $this->inner->getItems($keys);
} }
public function hasItem(string $key): bool public function hasItem(string $key): bool
{ {
return $this->inner->hasItem($key); return $this->inner->hasItem($key);
} }
public function clear(): bool public function clear(): bool
{ {
return $this->inner->clear(); return $this->inner->clear();
} }
public function deleteItem(string $key): bool public function deleteItem(string $key): bool
{ {
return $this->inner->deleteItem($key); return $this->inner->deleteItem($key);
} }
public function deleteItems(array $keys): bool public function deleteItems(array $keys): bool
{ {
return $this->inner->deleteItems($keys); return $this->inner->deleteItems($keys);
} }
public function save(CacheItemInterface $item): bool public function save(CacheItemInterface $item): bool
{ {
return $this->inner->save($item); return $this->inner->save($item);
} }
public function saveDeferred(CacheItemInterface $item): bool public function saveDeferred(CacheItemInterface $item): bool
{ {
return $this->inner->saveDeferred($item); return $this->inner->saveDeferred($item);
} }
public function commit(): bool public function commit(): bool
{ {
return $this->inner->commit(); return $this->inner->commit();
@@ -211,11 +230,11 @@ final class MakeNonceTraitTest extends TestCase
// should retry and succeed on the second attempt // should retry and succeed on the second attempt
$nonce = $obj->publicMakeNonce(); $nonce = $obj->publicMakeNonce();
self::assertIsString($nonce); self::assertIsString($nonce);
self::assertSame(20, strlen($nonce)); self::assertSame(20, \strlen($nonce));
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success'); self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
} }
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void public function test_make_nonce_throws_immediately_with_zero_retries(): void
{ {
$pool = $this->createStub(CacheItemPoolInterface::class); $pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class); $item = $this->createStub(CacheItemInterface::class);
+13 -13
View File
@@ -13,31 +13,31 @@ final class StringTraitTest extends TestCase
use StringTrait; use StringTrait;
use TotpTestHelper; use TotpTestHelper;
public function testMakeCacheKeySanitizesInvalidChars(): void public function test_make_cache_key_sanitizes_invalid_chars(): void
{ {
self::assertSame('hello_world', $this->makeCacheKey('hello world')); self::assertSame('hello_world', $this->makeCacheKey('hello world'));
self::assertSame('hello_world', $this->makeCacheKey('hello!world')); self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d')); self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
} }
public function testMakeCacheKeyPreservesValidChars(): void public function test_make_cache_key_preserves_valid_chars(): void
{ {
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc')); self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
} }
public function testMakeCacheKeyTruncatesLongNames(): void public function test_make_cache_key_truncates_long_names(): void
{ {
$long = str_repeat('a', 300); $long = str_repeat('a', 300);
$result = $this->makeCacheKey($long); $result = $this->makeCacheKey($long);
self::assertSame(128, mb_strlen($result)); self::assertSame(128, mb_strlen($result));
} }
public function testMakeCacheKeyEmptyString(): void public function test_make_cache_key_empty_string(): void
{ {
self::assertSame('', $this->makeCacheKey('')); self::assertSame('', $this->makeCacheKey(''));
} }
public function testMakeCacheKeyWithOnlyInvalidChars(): void public function test_make_cache_key_with_only_invalid_chars(): void
{ {
// preg_replace with + collapses consecutive invalid chars into one _ // preg_replace with + collapses consecutive invalid chars into one _
self::assertSame('_', $this->makeCacheKey('!!!')); self::assertSame('_', $this->makeCacheKey('!!!'));
@@ -46,7 +46,7 @@ final class StringTraitTest extends TestCase
self::assertSame('_', $this->makeCacheKey('!@ #')); self::assertSame('_', $this->makeCacheKey('!@ #'));
} }
public function testMakeCacheKeyTruncatesToExactly128(): void public function test_make_cache_key_truncates_to_exactly128(): void
{ {
$input = str_repeat('a', 128); $input = str_repeat('a', 128);
self::assertSame(128, mb_strlen($this->makeCacheKey($input))); self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
@@ -56,7 +56,7 @@ final class StringTraitTest extends TestCase
self::assertSame(128, mb_strlen($this->makeCacheKey($input129))); self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
} }
public function testMakeCacheKeyWithMultibyteChars(): void public function test_make_cache_key_with_multibyte_chars(): void
{ {
// multibyte chars are replaced with a single underscore // multibyte chars are replaced with a single underscore
$result = $this->makeCacheKey('héllo wörld'); $result = $this->makeCacheKey('héllo wörld');
@@ -64,7 +64,7 @@ final class StringTraitTest extends TestCase
self::assertSame('h_llo_w_rld', $result); self::assertSame('h_llo_w_rld', $result);
} }
public function testMakeCacheKeyWithEmoji(): void public function test_make_cache_key_with_emoji(): void
{ {
$result = $this->makeCacheKey('a🎉b'); $result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result); self::assertSame('a_b', $result);
@@ -72,7 +72,7 @@ final class StringTraitTest extends TestCase
/* ── authSuccessResponse ──────────────────────────────────────────── */ /* ── authSuccessResponse ──────────────────────────────────────────── */
public function testAuthSuccessResponseSessionMode(): void public function test_auth_success_response_session_mode(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'session'); $config = $this->makeConfig(remoteUserMode: 'session');
$response = $this->authSuccessResponse('alice', $config); $response = $this->authSuccessResponse('alice', $config);
@@ -82,7 +82,7 @@ final class StringTraitTest extends TestCase
self::assertSame('alice', $response->headers->get('Remote-User')); self::assertSame('alice', $response->headers->get('Remote-User'));
} }
public function testAuthSuccessResponseStaticMode(): void public function test_auth_success_response_static_mode(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated'); $config = $this->makeConfig(remoteUserMode: 'static', remoteUserStatic: 'authenticated');
$response = $this->authSuccessResponse('alice', $config); $response = $this->authSuccessResponse('alice', $config);
@@ -91,7 +91,7 @@ final class StringTraitTest extends TestCase
self::assertSame('authenticated', $response->headers->get('Remote-User')); self::assertSame('authenticated', $response->headers->get('Remote-User'));
} }
public function testAuthSuccessResponseMappedMode(): void public function test_auth_success_response_mapped_mode(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'); $config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('alice', $config); $response = $this->authSuccessResponse('alice', $config);
@@ -99,7 +99,7 @@ final class StringTraitTest extends TestCase
self::assertSame('admin', $response->headers->get('Remote-User')); self::assertSame('admin', $response->headers->get('Remote-User'));
} }
public function testAuthSuccessResponseMappedModeFallback(): void public function test_auth_success_response_mapped_mode_fallback(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin'); $config = $this->makeConfig(remoteUserMode: 'mapped', remoteUserMap: 'alice:admin');
$response = $this->authSuccessResponse('unknown', $config); $response = $this->authSuccessResponse('unknown', $config);
@@ -107,7 +107,7 @@ final class StringTraitTest extends TestCase
self::assertSame('unknown', $response->headers->get('Remote-User')); self::assertSame('unknown', $response->headers->get('Remote-User'));
} }
public function testAuthSuccessResponseNoneModeOmitsHeader(): void public function test_auth_success_response_none_mode_omits_header(): void
{ {
$config = $this->makeConfig(remoteUserMode: 'none'); $config = $this->makeConfig(remoteUserMode: 'none');
$response = $this->authSuccessResponse('alice', $config); $response = $this->authSuccessResponse('alice', $config);
+7 -5
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Unit; namespace App\Tests\Unit;
use App\Utilities; use App\Utilities;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface; use Psr\Clock\ClockInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -15,10 +16,11 @@ final class UtilitiesTest extends TestCase
{ {
$pool ??= new ArrayAdapter(); $pool ??= new ArrayAdapter();
$clock ??= $this->createStub(ClockInterface::class); $clock ??= $this->createStub(ClockInterface::class);
return new Utilities($clock, $pool); return new Utilities($clock, $pool);
} }
public function testLoadTotpReturnsCachedValueWhenPresent(): void public function test_load_totp_returns_cached_value_when_present(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$item = $pool->getItem('totp'); $item = $pool->getItem('totp');
@@ -32,7 +34,7 @@ final class UtilitiesTest extends TestCase
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result); self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
} }
public function testLoadTotpGeneratesAndStoresWhenMissing(): void public function test_load_totp_generates_and_stores_when_missing(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool); $utilities = $this->makeUtilities($pool);
@@ -48,7 +50,7 @@ final class UtilitiesTest extends TestCase
self::assertSame($result, $cached->get()); self::assertSame($result, $cached->get());
} }
public function testLoadTotpSetsFarFutureExpiry(): void public function test_load_totp_sets_far_future_expiry(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool); $utilities = $this->makeUtilities($pool);
@@ -58,10 +60,10 @@ final class UtilitiesTest extends TestCase
$cached = $pool->getItem('totp'); $cached = $pool->getItem('totp');
$expiry = $cached->getMetadata()['expiry']; $expiry = $cached->getMetadata()['expiry'];
// 2999-12-31 is well in the future, far beyond any reasonable test timestamp // 2999-12-31 is well in the future, far beyond any reasonable test timestamp
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); self::assertGreaterThan((new DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
} }
public function testLoadTotpIsIdempotentAfterGeneration(): void public function test_load_totp_is_idempotent_after_generation(): void
{ {
$pool = new ArrayAdapter(); $pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool); $utilities = $this->makeUtilities($pool);
+2
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php'; require dirname(__DIR__).'/vendor/autoload.php';