Credential lifecycle
The account surface gives the local realm a self-service password change — for users who know their current password. The credential lifecycle features finish the story around it: a password reset for users who do not, an invitation entry point so an account can start from nothing, and an optional TOTP second factor (available wherever password login is; enrollment confirms before anything enforces). Everything stays JDK-only and rides machinery that already exists: the identity contract pack, the notification channels and outbox, the bundled auth-ui and account apps, and the managed-table patterns.
One token store for reset and invitations
Section titled “One token store for reset and invitations”Reset and invite links share one token table, tql_credential_token, which the runtime
creates and manages automatically. For reference, its shape:
create table if not exists tql_credential_token ( token_hash varchar(64) primary key, -- SHA-256 of the token; the token itself is never stored login_id varchar(255) not null, purpose varchar(16) not null, -- reset | invite expires_at timestamp not null, used_at timestamp, created_at timestamp not null);- The mailed token is 256 random bits, URL-safe; only its SHA-256 lands in the row.
- Single-use is a check-and-set (
set used_at ... where token_hash = ? and used_at is null and expires_at >= now→ row count), so two racing confirms cannot both win. - Expiries: reset 30 minutes, invite 7 days (both configurable). Issue prunes expired rows opportunistically.
- Cooldown: a login with an unexpired, unused token of the same purpose is not issued another (silently — see anti-enumeration), which also caps mail volume.
Password reset
Section titled “Password reset”tesseraql: identity: recovery: enabled: true channel: user-mail # a configured mail channel url: https://app.example.com/_tesseraql/reset/confirm # absolute; the mailed link baseEnabling recovery without a channel:, or naming a channel that is not a mail channel, fails
the boot with TQL-SEC-4120 — a half configuration must never produce a reset page that goes
nowhere.
- The login page grows Forgot password? (only when enabled).
/_tesseraql/reset(public, auth-ui app) takes a login id; the POST always answers the same neutral “if that account can be recovered, a link is on its way” — whether or not the account exists, has an email, or is cooling down. No enumeration oracle. - The destination comes from a pack contract,
find-recovery-destination-by-login— default: thetql_users.emailof an ACTIVE user. Asqlrealm overrides the contract like any other. - The mail rides the outbox as a NOTIFICATION on the configured
channel: at-least-once, retries,
dead-letters; payload
{resetUrl, loginId, displayName}feeds the channel template. /_tesseraql/reset/confirm?token=…(public) shows the new-password form; the POST verifies + consumes the token, writes the hash through the existingupdate-passwordcontract, invalidates every session of that subject, and lands on the login page with a success flag. Used/expired/unknown tokens all render the same honest “this link is no longer valid”.
Invitations
Section titled “Invitations”Invitations are switched on by naming the mail channel and the link base together:
tesseraql: identity: invite: channel: user-mail # a configured mail channel url: https://app.example.com/_tesseraql/invite # absolute; the mailed link baseBoth keys are required: setting one without the other — or naming a channel that is
not a mail channel — fails the boot with TQL-SEC-4120, so a half configuration can
never send an invite that goes nowhere.
The bundled IAM Admin (/_tesseraql/admin/users) has Invite user: login id,
display name, email, roles. The provider runs the existing create-user contract with
status INVITED — no credential columns at all, and find-credential-by-login
already refuses any status but ACTIVE, so an invited account cannot sign in until
accepted — then issues an invite token and enqueues the mail (same machinery, purpose
invite, the configured url as the link base).
/_tesseraql/invite?token=… (public) sets the first password: verify + consume, the
update-password contract, then the existing enable-user contract flips the status
to ACTIVE. From zero to signed-in without an operator ever knowing a password.
Re-inviting a still-INVITED account is a polite resend under the token cooldown; an already-usable login refuses, so an invite can never take over an account.
TOTP second factor
Section titled “TOTP second factor”Totpintesseraql-security: RFC 6238 overjavax.crypto.Mac(HmacSHA1, 6 digits, 30 s steps, ±1 step window) plus a small Base32 codec — no new dependency.- Enrollment lives on the account page (account surface): generate a
secret, render it as a QR code (a server-side inline SVG over the
otpauth://URI — zxing computes the matrix, no imaging stack, no client scripting) alongside the Base32 text for manual entry, and confirm with a valid code before anything is stored intql_user_totp(subject, secret, confirmed_at, last_used_step). Disabling requires the current password (TQL-ACCOUNT-4804on mismatch). - Login: the password form (and the JSON login) gains an optional code field. A
confirmed enrollment makes it required — wrong or missing code fails exactly like a
wrong password (one neutral message; no “password ok, code wrong” oracle). The
accepted step is recorded and codes at or before
last_used_stepare refused — a captured code cannot replay inside its window. - Recovery codes: eight single-use codes are minted with the pending secret and
shown on the enrollment page — the same owner-only exposure as the pending secret
itself — with the instruction to save them now. Confirmation activates them: the
plain copies are dropped and only SHA-256 hashes live in
tql_totp_recovery. At the login prompt a recovery code goes in the same field as the 6-digit code and signs in once (consuming the hash is the single-use guarantee); wrong or spent codes fail exactly like a wrong password. Re-enrolling mints a fresh set, replacing the old.
Security posture
Section titled “Security posture”- Tokens: 256-bit, hashed at rest, single-use by check-and-set, short-lived, purpose- bound; the confirm pages never echo the token back into markup.
- Anti-enumeration end to end: the reset request answers identically for unknown logins, missing emails, and cooldowns; the confirm page answers identically for unknown, used, and expired tokens; the TOTP login failure is indistinguishable from a wrong password.
- A consumed reset invalidates every session of the subject.
- The TOTP secret is server-side data in the identity database, like the password hashes beside it — protect that database accordingly.
- account.md — the self-service surface these flows serve.
- iam-admin.md — inviting and disabling users.