Skip to content

Commit d98c4ee

Browse files
turegjorupclaude
andcommitted
env:init: stop wiping the JWT keypair; add self-healing jwt:ensure
env:init deleted ./jwt/*.pem whenever it rotated JWT_PASSPHRASE, on the assumption that `task install`'s generate-keypair --skip-if-exists would regenerate. Upgrade paths that use `task up` (not install) — e.g. clone -> v3 — never regenerate, so the wipe stranded them with no keypair and login 500'd ("Unable to create a signed JWT"). env:init no longer touches the keypair; it only warns when a rotated passphrase orphans an existing one. Key lifecycle moves to a new idempotent `jwt:ensure` task: it runs lexik:jwt:check-config and regenerates (--overwrite) ONLY on a missing/invalid/mismatched keypair, so a valid keypair (and its issued tokens) survives. `task install` now calls jwt:ensure instead of generate-keypair. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dffb08b commit d98c4ee

4 files changed

Lines changed: 41 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,7 @@ Operations
12591259
logs:disk Docker log disk usage per container + retention policy (Linux only)
12601260
console Run any bin/console command in os2display (e.g. `task console -- list`)
12611261
console:run Run a one-off bin/console in a throwaway container (migrate before `up`)
1262+
jwt:ensure Ensure the JWT keypair exists/matches JWT_PASSPHRASE; regenerate on mismatch
12621263
cache:clear Clear the application cache (alias: cc)
12631264
php:opcache Report on the FPM pool's OPcache health (RAW=1 for JSON)
12641265
tenant:add Add a tenant group (interactive) (alias: tenant_add)

Taskfile.yml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,8 @@ tasks:
4545
- "{{.COMPOSE}} up --force-recreate --detach --remove-orphans --wait"
4646
- echo "Clearing the cache"
4747
- task: cache:clear
48-
- echo "Create jwt key pair"
49-
- task: console
50-
vars: { CLI_ARGS: "lexik:jwt:generate-keypair --skip-if-exists" }
48+
- echo "Ensure the JWT keypair exists and matches JWT_PASSPHRASE"
49+
- task: jwt:ensure
5150
- task: tenant:add
5251
- echo "CREATE AN ADMIN USER. CHOOSE THE TENANT YOU JUST CREATED."
5352
- task: user:add
@@ -174,6 +173,22 @@ tasks:
174173
cmds:
175174
- "{{.COMPOSE}} run --rm {{if .EXEC_FLAGS}}{{.EXEC_FLAGS}} {{end}}os2display bin/console {{.CLI_ARGS}}"
176175

176+
jwt:ensure:
177+
desc: |
178+
Ensure a JWT keypair exists and matches JWT_PASSPHRASE — (re)generate ONLY
179+
if it's missing, unreadable, or doesn't validate. Safe to run on every
180+
install/upgrade: a valid keypair is left untouched, so existing tokens
181+
survive; an absent or mismatched one is regenerated (which invalidates
182+
issued tokens — screens must re-authenticate). Requires the stack up.
183+
cmds:
184+
- |
185+
if {{.COMPOSE}} exec -T os2display bin/console lexik:jwt:check-config >/dev/null 2>&1; then
186+
echo "JWT keypair OK — leaving it untouched."
187+
else
188+
echo "JWT keypair missing or invalid for the current JWT_PASSPHRASE — regenerating."
189+
{{.COMPOSE}} exec -T --user deploy os2display bin/console lexik:jwt:generate-keypair --overwrite
190+
fi
191+
177192
cache:clear:
178193
desc: Clear the application cache
179194
aliases: [cc]

UPGRADE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,10 @@ admin commands sometimes do.
231231
task install # pulls the rest of the images, runs `bin/console app:update`
232232
# (Doctrine migrations + cache:warmup) in a one-off container
233233
# BEFORE bringing the web tier up, then brings up the full
234-
# stack, generates the JWT keypair if missing, and interactively
235-
# prompts for tenant + admin user (skip these if you already
236-
# have them — your old data is still there).
234+
# stack, ensures the JWT keypair matches JWT_PASSPHRASE
235+
# (regenerating only on mismatch), and interactively prompts
236+
# for tenant + admin user (skip these if you already have
237+
# them — your old data is still there).
237238
```
238239

239240
`task install` reuses the existing data: the bundled mariadb's named volume from 1.x carries

scripts/env-init.sh

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
# variable rc3+ uses inside DATABASE_URL. Either way, the bundled
1515
# value can't drift behind the stack and Doctrine picks the right
1616
# SQL dialect.
17-
# - ./jwt/{private,public}.pem: wiped if present, because rotating
18-
# JWT_PASSPHRASE orphans whatever keypair the previous install
19-
# generated. `task install`'s `lexik:jwt:generate-keypair
20-
# --skip-if-exists` then regenerates fresh against the new
21-
# passphrase. Skipping this wipe lets the runtime fail login with
22-
# "Unable to create a signed JWT from the given configuration"
23-
# (auth succeeds, signing fails).
17+
# - ./jwt/{private,public}.pem: left ALONE. This script never deletes
18+
# operator key material — a wipe here is invisible to upgrade paths
19+
# that don't run `task install` (e.g. clone -> v3 via `task up`),
20+
# stranding them with no keypair. Instead, when a fresh .env.symfony
21+
# rotates JWT_PASSPHRASE, we warn that any existing keypair no longer
22+
# matches; `task jwt:ensure` (run by `task install`, and available
23+
# standalone) validates the keypair and regenerates only on mismatch.
2424
#
2525
# This is the single bootstrap entry point. `task install` / `task up` /
2626
# `task update` precondition on `.env.symfony` existing — running this is
@@ -105,31 +105,24 @@ rm -f .env.symfony.bak
105105

106106
# JWT_PASSPHRASE was just rotated to a fresh random value. Any pre-
107107
# existing keypair at ./jwt/{private,public}.pem was encrypted with
108-
# whatever passphrase preceded this run — it's now orphaned and
109-
# `task install`'s `lexik:jwt:generate-keypair --skip-if-exists`
110-
# won't notice the mismatch, so login would 500 with "Unable to
111-
# create a signed JWT from the given configuration." (auth succeeds,
112-
# JWT signing fails). Wipe the orphans here; `task install`
113-
# regenerates fresh against the new passphrase.
114-
#
115-
# Route the rm through a transient alpine container: the keypair is
116-
# written by the os2display container's UID 1042 `deploy` user, so
117-
# on Linux hosts a host-side `rm` fails with "Permission denied"
118-
# unless the operator runs as UID 1042 or has sudo. Docker Desktop
119-
# (macOS/Windows) brokers ownership at the VM boundary, so a host
120-
# `rm` would work there — but the container path is portable.
121-
JWT_WIPED=""
108+
# whatever passphrase preceded this run, so it no longer matches. We do
109+
# NOT delete it here — `task jwt:ensure` (run by `task install`) validates
110+
# the keypair against the passphrase and regenerates only on mismatch, so
111+
# the fix works whether or not `task install` is the next step. Just flag
112+
# the orphan so the operator isn't surprised.
113+
JWT_ORPHANED=""
122114
if [ -f jwt/private.pem ] || [ -f jwt/public.pem ]; then
123-
docker run --rm -v "$PWD/jwt:/jwt" alpine rm -f /jwt/private.pem /jwt/public.pem
124-
JWT_WIPED="yes"
115+
JWT_ORPHANED="yes"
125116
fi
126117

127118
echo
128119
echo ".env.symfony created from $IMAGE."
129120
echo " APP_SECRET / JWT_PASSPHRASE: random 32-byte hex (auto-generated)"
130121
echo " DATABASE_URL serverVersion: ${MARIADB_TAG}-MariaDB (matched to compose pin)"
131-
if [ -n "$JWT_WIPED" ]; then
132-
echo " ./jwt/*.pem: wiped (orphaned by rotated JWT_PASSPHRASE)"
122+
if [ -n "$JWT_ORPHANED" ]; then
123+
echo " ./jwt/*.pem: kept, but no longer match the rotated"
124+
echo " JWT_PASSPHRASE — 'task jwt:ensure' (run by"
125+
echo " 'task install') regenerates them on mismatch."
133126
fi
134127
echo
135128
echo "Edit .env.symfony for any ADMIN_*, CLIENT_*, OIDC_*, or DATABASE_URL"

0 commit comments

Comments
 (0)