Skip to content

feat(auth): add email_password sign-in with reset and revocation - #7

Merged
ruko1202 merged 10 commits into
mainfrom
feat/password-argon2id-seed-reset
Sep 6, 2026
Merged

feat(auth): add email_password sign-in with reset and revocation#7
ruko1202 merged 10 commits into
mainfrom
feat/password-argon2id-seed-reset

Conversation

@ruko1202

@ruko1202 ruko1202 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Makes a password the second built-in sign-in method: argon2id hashes in
auth_credentials, self-service reset over the existing one-time code flow, and
session revocation whenever a password changes. RUK-289.

  • Add argon2id hashing, PHC-encoded, with parameters read back out of each
    stored record so retuning them never strands existing hashes; a record that is
    not well-formed argon2id (a sha256 digest in the password column, a truncated
    row) fails loudly instead of reading as a mismatch
  • Extend POST /login/password beyond break-glass: a user with a stored password
    is verified against it, and the submitted address now selects the method
    rather than being ignored
  • Add POST /me/password and POST /password/reset/{request,confirm}; a change
    keeps the calling session and evicts the rest, a reset evicts every session
  • Equalize the endpoint's timing: every failing branch pays one argon2id
    verification, including the break-glass one, which previously answered orders
    of magnitude faster and located the configured admin address
  • Report password_set on GET /me so a client knows which form to draw, and fix a
    policy failure on /me/password answering 500 instead of 400
  • Audit one-time-code failures that carry no named reason, which used to vanish
    from the trail entirely; retarget the login alert, whose old remediation
    advice no longer fits a route that serves ordinary users

Adds HashPassword/VerifyPassword over golang.org/x/crypto/argon2, PHC-encoded,
plus the shared length validator every entry point calls. Parameters are
m=64 MiB, t=3, p=1; p is 1 because the lanes partition the same arena rather
than buying parallel speedup, and a value tied to host CPU count would make a
hash's cost depend on which replica computed it.

Two properties are load-bearing and pinned by tests.

Verification reads m/t/p out of the stored record rather than assuming the
current constants, so retuning them later does not strand existing hashes. The
test uses a literal PHC fixture written under other parameters, which cannot
drift toward the constants and survives changing them.

A record that is not a well-formed argon2id string -- a sha256 digest that
reached the password column, a truncated row, an out-of-range cost -- returns
ErrMalformedHash rather than a quiet mismatch. Collapsing the two would hide a
wrong-hash-function bug behind what looks like a bad password. Parsed cost
values are bounded, because the record is data: a row claiming a huge arena
would otherwise be honored and exhaust the process.

The PHC codec is hand-rolled because x/crypto/argon2 ships only the KDF and no
encoder, and the libraries that parse PHC would be a new dependency in the
authentication path.

Promotes golang.org/x/crypto to a direct dependency; it was already indirect.
UpsertPassword writes or replaces a password in one statement, through the jet
builder. The arbiter is a partial unique index, so the conflict target repeats
its predicate -- ON CONFLICT (user_id) WHERE kind = 'password' -- which is what
makes Postgres infer auth_credentials_password_uidx instead of looking for a
total index on user_id.

That predicate is a raw expression rather than a built one, and the reason is
worth recording: postgres.String() renders a ::text cast, and
`kind = 'password'::text` does not match the index's `WHERE kind = 'password'`.
Inference then fails with 42P10 at runtime -- it compiles, the first insert
succeeds, and only the second one, the one that conflicts, errors. The store
tests catch it, verified by putting the cast back.

The DO UPDATE clears attempts, expires_at, consumed_at and session_nonce: those
belong to one-time codes sharing this table, and a row that held one would
otherwise carry its state into a live password. Pinned by a test.

Replacing in place rather than retiring the old row via consumed_at is
deliberate: neither GetPasswordByUserID nor the unique index filters on it, so a
soft-deleted password would still occupy the slot and still read as live.

RevokeByUserIDExceptFamily backs a password change made from a live browser:
every other session is evicted -- the point of changing a password after it
leaked -- while the one doing the changing survives. FamilyByRefreshToken
resolves which session that is, verifying ownership, because the family is not
in the access token and TokenPair.SessionID never reaches the wire.
/login/password stops being hardwired to the break-glass provider. Resolution is
now: a user with a stored password is verified against it; otherwise, if the
submitted address is the configured bootstrap one, the break-glass password is
tried; otherwise the attempt fails.

Step order matters. Once the admin has a password of their own, the break-glass
one is no longer consulted for them -- the personal credential wins as soon as
it exists, and the shared one stays for emergencies.

Two behavior changes worth naming. The request body email is no longer ignored
-- it selects the method, so a break-glass password submitted against another
address signs nobody in. And a credential written by the wrong hash function
fails the login loudly rather than reading as a mismatch.

Every failing branch pays exactly one argon2id verification. Where no stored
password is found the handler verifies against a fixed decoy instead, and the
break-glass branch pays it too: Authenticate is a constant-time compare over a
config string, so without the decoy a wrong guess against the configured address
answered orders of magnitude faster than against any other, locating the admin's
address by timing alone. A test measures both branches.

Adds the password.changed and password.reset audit actions, in both direction
maps -- the reverse one feeds the category filter, so an action listed in only
one is written correctly and then never appears when a user filters by its own
category. A test derives that check from the maps themselves rather than a
hand-kept list.
Three endpoints. POST /me/password sets the caller's own password;
/password/reset/{request,confirm} reuse the existing one-time code mechanism
unchanged.

installPassword holds what both writers share: the password write and the
session revocation in ONE transaction, with the revocation passed in as a
callback so it runs inside it. Splitting them would leave a window where the
password is new but the old sessions still work, which is exactly what someone
replacing a leaked password is closing. Tested in both directions: a revocation
that fails rolls the password back.

/me/password requires current_password when one is set and refuses it when none
is -- both directions are errors, so a client learns which state it is in.
refresh_token names the session to spare and is OPTIONAL; omitting it revokes
everything including the caller's, which is the only way an admin who has lost
it can still set a password. A reset revokes every session: the proof was
possession of the mailbox, and the person resetting may be recovering an account.

The reset policy check runs BEFORE the code is redeemed and answers identically
to a bad code. Checking after would tell a caller holding a guessed code that
the code was right, and would spend an attempt on a request the user is about to
retry. Pinned by handler tests asserting the failures are byte-identical.

A blocked user is refused explicitly -- Verify does not check blocking, which
lives in IssueAccessToken, and this path issues no tokens.

The transaction starts after Verify returns, leaving its no-transaction contract
intact. Accepted consequence: a write failing after redemption burns the code,
which is the safe direction.
Registers the three new routes, mounts the password gate at every
RequireAccessToken site, and completes the wiring. The router file lands here
rather than split across the earlier commits because its hunks interleave: the
gate mount and the route registration sit in the same functions, and separating
them would leave neither commit building on its own.

The reset routes take the SAME three limiter tiers as the sign-in codes, because
they issue the same codes through the same mailer: leaving them on the per-IP
bucket would be a second, cheaper door to it.

Rewrites BootstrapLoginFailing as PasswordLoginFailing. The old rule read every
non-429 4xx on /login/password as a break-glass brute force and told the
operator to set a strong bootstrap password. That route now serves ordinary
users against their own hashes, so the advice is wrong for almost every firing,
and a real security alert turned into noise is worse than none. The threshold
rises accordingly, and the annotation says to check whether failures concentrate
on the configured bootstrap address -- which is what an attempt on the admin
credential looks like.
otp.Verify leaves the audit reason empty when it fails infrastructurally -- a
database that did not answer while resolving the user, reading the live code,
claiming an attempt, or consuming it. Those failures were dropped from the
trail entirely, on the reasoning that a database error is not a judged
credential.

That reasoning is half right and the conclusion is wrong. An attempt was made
and it was refused; a trail that omits it reads as though nothing happened,
which is the wrong answer to give an operator reading it after an incident --
and this is the one record they can reach when sign-in is what broke.

The distinction it was protecting survives: the event is recorded AS unknown
rather than dressed up as an invalid code, so nothing claims a credential was
judged when none was. The raw error stays out of the payload, which is a
whitelist by design.

Reverses the test that pinned the old rule.
The frontend needs to know which form to draw -- setting a first password or
replacing an existing one -- and it cannot work that out on its own. Nothing
else in the profile implies it, and a value inferred at sign-in does not
survive a page reload.

password_set mirrors what POST /me/password enforces: current_password is
required when one exists and refused when none does, both a 400, so a client
that guesses wrong gets an error rather than a form it can submit.

A read failure degrades to false rather than failing the profile. The field
steers a form; guessing wrong costs the user a retry, a 500 costs them the page.

Also fixes a policy failure on /me/password answering 500. ErrPasswordPolicy
comes from xcripto and was not wrapped in apperr.ErrValidation, so the mapper
fell through to its default arm -- a password one character short returned an
internal error. Unlike the reset path there is nothing to hide here: the
endpoint is authenticated and the caller owns the account, so it now answers 400
and says the password was too short.
`go test -race` reports a data race between any two concurrent password writes,
and it is not a test artifact: postgres.NULL is ONE package-level value shared
by the whole process, and jet's typed wrappers mutate it in place by calling
setRoot on it while building the statement.

Two people changing their passwords at the same moment take that path in
production, so this would have raced there too. It went unnoticed because the
ordinary `make tloc` runs without -race; only `make tloc-cov` catches it.

The nulled columns now go through CAST(NULL), which wraps the shared value in a
fresh expression before the typed wrapper touches it, so the mutation lands on
something this statement owns. The generated SQL carries an explicit
::timestamp with time zone, which is what a bare NULL would have been inferred
as anyway.

The concurrency test already covered this path; its comment now names what it
guards, since a plain run passes either way. Verified by putting the bare
wrapper back and watching -race report the race again.
POST /me/password with the wrong current_password returned an internal error.
apperr.ErrInvalidCredentials was missing from the auth mapper entirely, so it
fell through to the default arm -- a client could not classify the failure, and
a BFF routing on status would bounce the operator to sign-in on what is really
a refusal.

The login routes never noticed: they answer failures themselves and bypass this
mapper on purpose, to keep every rejection identical.

Adds handler tests pinning all three statuses this endpoint can answer -- 401
for a wrong current password, 400 for one below the length floor, 204 on
success. Unlike the login and reset routes these are told apart deliberately:
the caller is authenticated and owns the account, so a form can say which of
the two went wrong.

Found by the frontend session building against this contract, which had assumed
401 and would have shipped against a 500.
GetMaints chose its two defaults independently: the lower bound from the
caller, the upper one from the end of TODAY. A caller supplying only
period_from -- tomorrow, say -- therefore built a range whose lower bound sat
above its upper, and Postgres rejected the query outright with 22000, "range
lower bound must be less than or equal to range upper bound". The request
failed with a 500 where the honest answer is "the rest of that day".

The upper default now comes from periodFrom instead, so an open-ended filter
always describes a valid window.

This is also why TestList/no_overlap looked flaky in CI. It asks about a
maintenance starting two hours out, so it built an inverted range only when the
run landed in the last two hours of a UTC day -- red overnight, green all
morning. The behaviour was time-dependent in a way nothing declared, which is
the part worth fixing rather than the test.

Adds a regression test using an explicit far-future period_from, so it fails at
any hour rather than reproducing the defect only after 22:00 UTC.
@ruko1202
ruko1202 merged commit 506e0b0 into main Sep 6, 2026
6 checks passed
@ruko1202
ruko1202 deleted the feat/password-argon2id-seed-reset branch September 6, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant