Your CI caught this: the check passed on SQLite and never fired on the
stack where the database container runs on a different timezone from the
app, so a stale token stayed valid. The comparison was between a token's
`iat`, which is UTC seconds from Node, and `auth.modified_on`, which the
driver hands back interpreted in the app's timezone. With the app on
Australia/Brisbane and the database on UTC, that column comes back ten
hours in the past and the token always looks newer than the change.
Record the moment in `auth.meta.password_changed_at` instead, written by
`setPassword` with the same `Date.now()` clock that mints `iat`. Same
unit on both sides, one clock, and no timestamp parsing: the Date and
local-string branch is gone, and so is the whole-second flooring that
Postgres microseconds made necessary.
Rows written before this have no marker and revoke nothing until their
next password change, which is the safe direction to be wrong in.
Tokens are stateless JWTs, so changing a password left every session that
the old one had opened working until its own expiry, up to a day later.
That is the case the password change is meant to close: an administrator
resetting a compromised account did not evict whoever was already in it.
The auth row already records when the password last changed, so no
migration is needed: `Access.init()` reads it alongside the user it
already loads and refuses a token whose `iat` is older. Both sides are
compared as whole seconds, which is all `iat` carries, so a token minted
in the same second as the change is kept. Postgres stores that column to
the microsecond, which is why the comparison is not done in milliseconds.
It is reported as 401 rather than the usual 403 because that is what the
frontend clears the session on, so the browser holding the dead token
lands on the login page instead of a page full of errors, and `can()`
lets that one error through unwrapped for the same reason.
Only the password does this. A user row changing (a rename, an avatar,
permissions) does not, and a user with no password auth row, which is
what a login through an external provider looks like, is not affected.
The template was the longest of all plugins (1181 characters, nine comment lines) and carried the upstream example secret that trips secret scanning. It now shows what a user has to fill in: the service principal, and one zone line in the format ZONE_NAME:RESOURCE_GROUP_ID, plus a link to the docs for everything else.
certbot-dns-azure 2.6.1 requires certbot<4.0; installing it into the image's certbot 5.x venv downgrades certbot and acme to 3.3.0, and acme 3.3.0 no longer imports against pyOpenSSL 26 (#5606). The maintained fork certbot-dns-azure-modern keeps module, entry point, flags and credentials format, requires certbot>=3.0 without an upper bound and declares its own azure-mgmt-dns range, so the extra dependency pin is no longer needed.
setupCertbotPlugins() wrote a credentials file for every DNS-01 certificate
each time the backend started, using flag "wx" so it only filled in missing
ones. That existed because the renew path did not write the file itself, so
something had to put it back before `certbot renew` looked for it.
With the previous commit the renew path writes the file immediately before
invoking certbot, so this is now the only thing putting those credentials back
on disk - and it does so for every certificate on every restart, which undoes
the cleanup entirely.
Removing the write leaves the `fs` import and the `promises` array unused. The
"Added Certbot plugins" log line is kept but now gates on plugins.length, since
it was previously gated on a promise array that only ever held credential
writes.
The credentials file written for a DNS-01 challenge was only cleaned up when
certbot failed - the unlink sat in a catch block. On success the file stayed in
/etc/letsencrypt/credentials for the entire life of the certificate, holding a
live DNS provider API token in plaintext.
The file cannot simply be deleted at issuance, because certbot records its path
in the renewal config and reads it back on every `certbot renew`. So the renew
path now writes the file itself immediately before invoking certbot, and both
paths remove it in a finally block.
Net effect: the credentials exist on disk for the duration of a certbot run
rather than permanently. The value still lives in the certificates table, which
is unavoidable - it has to come from somewhere to be written at all.
renewLetsEncryptSslWithDnsChallenge reads the row directly from the model
because renew() sources its certificate from internalCertificate.get(), which
strips meta.dns_provider_credentials via omissions().
processItems() passed proxyHostModel and the literal "proxy_host" to
configure() for every host type. host_type selects both the template and
the output path, and each host type has its own id sequence, so
redirection hosts, 404 hosts and streams were rendered through
proxy_host.conf and written over /data/nginx/proxy_host/<id>.conf. The
proxy host sharing that id lost its config file and had the resulting
nginx error recorded in its own meta.
When `nginx -t` fails, configure() is meant to move the broken config to
<id>.conf.err so the failure can be inspected. renameConfigAsError()
unlinked the source file before renaming it, so the rename always failed
and the config was simply deleted. The deleteConfig() call after it then
removed any .err file left over from an earlier failure.
- unlink the destination .err file instead of the source
- return the rename promise so the delete does not race it
- pass delete_err_file = false so the new .err file survives
- drop the stale 4th argument in the success path, which silently made
delete_err_file false and left old .err files behind