Changelog
WIPRelease history for @gentleduck/auth. Newest releases at the top.
This page mirrors the package changelog shipped inside @gentleduck/auth at
packages/duck-auth/CHANGELOG.md. That file is the source of truth; this page is
kept in sync with it by hand. Entries run newest first. Names in each entry are the
names that were current in that release, so an old entry can name something that has
since been renamed or removed.
5.13.0
Minor Changes
7228f81: Expose
updatedAton the session, credential and api-key surfaces.Every SQL dialect already carried an
updated_atcolumn, maintained by drizzle's$onUpdate, and every
dialect then projected it back out of the row before a caller could see it. Identities.Me exposed its
own; Sessions.Me and Credential.Me did not, so there was no way to ask when a session or a credential
was last written.
Sessions.Me.updatedAtandCredential.Me.updatedAtare now part of the row, and so isCredential.Public, which isMeminus the secret.ApiKeys.ApiKey.updatedAtcomes through the projection over the backing credential row.- The OpenAPI
Sessionschema declares it, so generated clients type it. - The memory and redis stores stamp it on every write, matching what
$onUpdatedoes in SQL. A caller cannot set it through a patch: it tracks the write, and is not backdatable. - A redis session written before this release has no
updatedAtfield. It reads back as itscreatedAtrather than being refused, so an upgrade does not sign existing sessions out.
The store-compliance suite now asserts, on all six backends, both that the field is present and typed and that every write actually moves it — an exposed timestamp that never changes would be worse than no timestamp at all.
Implementors of a custom Sessions.Store or Credential.Store must return updatedAt on the rows they
answer, and Sessions.CreateInput now carries it; the engine supplies it at create.
Stop sending the session's CSRF hash to the browser.
GET /session answered JSON.stringify(resolved.session), and the row carries csrfHash. All seven HTTP
adapters did it, so the per-session hash reached every client that asked who it was, for a field no client
has a use for: the browser echoes the plaintext from its __Host- cookie, and the comparison is
server-side. Identities.exportAll had always stripped the field, so the package's own intent was clear
and the route was the one place contradicting it.
- The handlers strip it inline, so the wire now carries the row minus that one field.
Sessions.Publicnames that shape, andVanillaClient.SessionResult.sessionplusreviveSessionare typed to it rather than toSessions.Me. A caller readingsession.csrfHashoff a client result now fails to compile, which is the point.- Two guards, both mutation-checked: a static-analysis test that scans every
server/*/index.tsfrom the directory rather than a list, so a new adapter is covered the day it lands, and a live assertion on the hono route's response body.
Answer the facts the API already held but did not hand back.
- The OpenAPI
Sessionschema declared 12 of the row's fields.ip,userAgent,fingerprintandactingAsare on the wire and non-secret, so a generated client was typed narrower than its own responses. They are declared now;csrfHashstays out, deliberately. ApiKeys.createaccepts atenantIdandexchangereports one, butApiKeys.ApiKeydropped it, so a key could be scoped to a tenant and never read back. It is now on the record, absent when unscoped.
Still deliberately hidden: Credential.secret, Sessions.Me.csrfHash outside the server, and mysql's
generated passwordKey/emailNorm/usernameNorm, which are index carriers rather than fields.
Mark auth responses uncacheable.
Nothing in the package set Cache-Control — not on a route, not in a shared helper, and not as advice in
the docs. GET /session is the sharp end: one URL for every caller, a body that differs by cookie, and no
Vary. A CDN or proxy caching it hands one caller another's identity and session. The same applied to the
magic-link and OAuth callback GETs, whose URLs carry a one-time token and an authorization code.
Every HTTP adapter now sets cache-control: no-store on the responses it builds, including error and
redirect paths. grpc is exempt: it is not HTTP. A static-analysis test enforces it per adapter, derived
from the directory rather than a list, and the hono session route asserts the header on a live response.
Close the timing oracle on the magic-link request.
requestPasswordReset is careful about this: both branches mint and hash a token and make the same store
calls in the same order, because "response time answers what the response body refuses to". magicLink.begin
implements the same contract and did the opposite — an unknown address returned before the token mint and
before the credential insert, so it was measurably the faster one, and the endpoint told a caller whether an
address had an account. Its comment claimed the branches matched; only the channel dispatch did.
- The unknown branch now mints and hashes a token like the known one, and answers the write with a read of
the same table under the same tenant scope. A decoy write is not possible:
auth_credentials.identity_idis a foreign key, which is the reason the reset flow reads there too. autoCreateIdentityandautoCreateProfilebehave as before; only the early returns are gone.
The existing magic-link-timing-defense.test.ts passed throughout, both before and after. It compared
wall-clock across the two branches with a 50 ms tolerance, against the memory adapter, where the skipped
insert costs microseconds — it could only ever prove that neither branch blocks on the channel. It now also
counts the store round trips each branch makes and requires them to line up, which is the assertion that
fails on the old code: the unknown branch made none where the known one made two.
CSRF-guard the passkey routes, and bound passkey.begin.
mountHono mounts passkey/begin and passkey/complete as POSTs. Neither called csrfGuard, while
/signin, /providers/:id/begin and all five MFA routes in the same file do — and passkey/complete is
a sign-in, the same thing /signin is. There was no note anywhere saying the omission was meant. Both are
guarded now, and a new test walks the routes mountHono actually registers and requires every POST among
them to refuse a cross-site request, so a route mounted without the guard fails the day it lands rather
than whenever someone next reads the file.
passkey.begin also consumed no rate limit, where passwords, magic-link, api-key and saml each bound
their entry point and flows.signIn has no central limiter to fall back on. It is keyed on the canonical
address when one is supplied, and on the session id otherwise. limiterKeyPrefix joins the other passkey
options, defaulting to passkey:begin:.
Refuse a revoked passkey on the branch that mints the session.
passkey.complete read cred.revokedAt for its truthiness, where the offer path twelve lines above uses
isRevoked and carries a comment saying why: a revokedAt of 0 is falsy, so a store keeping timestamps
as epoch ints would answer a revoked row and this read it as live. The same bug was found and fixed for
TOTP and for beginWebauthnMfaVerify, both of which now note it in place; the accept path was missed, and
it is the more consequential of the two, since the offer path only lists a credential while this one
returns startSession. No adapter shipped here can produce it — Credential.Me['revokedAt'] is
Date | null and new Date(0) is truthy — so this closes it for the pluggable store contract and for
consistency, not against a live exploit. A test drives the assertion through a store answering 0 and
asserts the mismatch; with the old check it resolved to startSession.
Make the OpenAPI spec describe the routes the adapters actually mount.
buildOpenApiSpec declared a route layout the framework adapters have never mounted. Six of its nine
paths did not exist: /password/sign-in against a mounted /signin, /sign-out against /signout,
/passkey/verify against /passkey/complete, and /oauth/{provider}/start plus /magic-link/request
against the single /providers/{id}/begin that drives every two-step provider. The five mounted MFA
routes were absent altogether. The spec's own info.description calls these "routes mounted by
@gentleduck/auth framework adapters", so a client generated from it 404s on sign-in and sign-out. The
existing test pinned the wrong paths, which is why it stayed quiet.
The path table now follows mountHono one for one, GET /session answers a SessionResult envelope
rather than a bare Session (both members are null when nothing resolves, and that is a 200), and the
gate names match the adapters' skip flags — so OpenApi.Cfg['providers'] takes
'magic-link' | 'oauth' | 'passkey' | 'totp'. 'password' is gone, because /signin is mounted
unconditionally and takes any registered provider by id. Anyone generating a client from this spec
gets different paths, which is the point: the old ones did not resolve.
A new openapi-route-parity test mounts the router against a recorder and diffs it against the spec,
at default config and once per skip group, so a route added, renamed or gated on one side fails there
instead of in someone's generated client.
The four routes also advertised an Idempotency-Key header. auth.idempotency is real and works, but
it is a facet a host wraps its own routes in — no adapter reads the header, so the spec was promising
that a retry replays the cached response when every retry executes again, sending a second magic-link
mail or burning another rate-limit token. The parameter is dropped and a test now asserts the spec does
not mention it. Wiring the header into the adapters is a feature, not a fix, and is left alone.
Describe the sign-in response the routes actually send, and stop SignInResult describing one nothing does.
Correcting the paths left the schemas untouched, and they were wrong in the same direction. SignInResult
declared a 200 of either { session } or { mfaRequired: true, methods }. Neither is ever sent:
mfaRequired appears nowhere in the package outside this file, because a second factor is not a 200 with
a flag — flows.signIn throws AUTH_STEP_UP_REQUIRED, which the adapters map to a 401 carrying
detail.challenge. And a successful sign-in sends no body at all; the session rides in the Set-Cookie
the transport issues, which is why the client re-reads it with GET /session. The schema is gone, the
four sign-in completing routes declare an empty 200 with a documented Set-Cookie and a 401 that names
the step-up code.
This one is pinned to the wire rather than to another list: the new test drives a real password sign-in through the mounted handler and asserts the status, the empty body and the cookie, then asserts the spec declares no content for that 200. It failed on exactly that last assertion before the fix.
Say that the client signUp route is yours to mount.
signUp defaults to POST /signup and its docs named that path the way signIn names /signin — but
no adapter mounts a registration route, so the default 404s while the neighbouring default works. The
method is still worth having, since it carries the CSRF header, the same-origin credentials and the
envelope that a bare fetch would not; the docs now say the route is the host's and the default is a
placeholder. A test pins the three client defaults the library does back, and pins /signup as absent so
mounting one later cannot quietly contradict SignUpOptions.
Stop the client reporting a signout the server refused.
client.signOut() discarded the envelope call had just built and answered { ok: true } whatever
happened — a 500, a CSRF refusal, an unreachable host. Clearing the local session regardless is right:
the caller asked to sign out, and a cached session outliving the request is worse than none. Reporting
success is not, because the session is still live on the server and the cookie is still in the browser,
which is the one thing signout exists to rule out. It now clears local state exactly as before and
returns what the server said, the way signIn already surfaces its failures. Callers that assumed
ok is always true on this method will now see false, which is the point. Two tests cover the
refused and the unreachable case; neither path had one, which is why this stood.
The as cast there was holding up a second untruth: the method declared
Envelope<Record<string, never>, string> while the route answers an empty body, so data is null at
runtime and has always been. signOut is typed Envelope<unknown, string> now, matching signUp, and
the cast is gone. The same declaration appeared in the react, vue, solid and svelte bindings, all four of
which delegate to the vanilla client and so carried the same lie; all four are corrected.
Cover the react client, which nothing did.
./client/react is a published entry point with no tests at all, while the vue, solid and svelte
bindings each have some. React hooks need a renderer and react-dom is not a dependency here, so the new
test pins what is checkable without one: that the module imports against the installed React and exports
its whole surface, so a dropped export or a broken barrel fails here rather than in someone's app.
Worth a decision, not changed here: react exposes useSignUp and useBeginProvider, and the vue and
solid bindings expose neither. Nothing is unreachable — every binding exposes the client itself, so
client.signUp(...) and client.beginProvider(...) are always in reach — but the hook-level surface is
uneven between siblings.
Repair a rename sweep that replaced _ with / inside names that were never paths.
An earlier refactor rewrote import specifiers by replacing duck-auth with auth/, and the replace ran
over every string that shared the prefix. The import paths were cleaned up afterwards; the strings that
merely looked like them were not, and they are not the kind of thing a type-checker reads:
duck-auth initscaffoldedprocess.env.DUCK_AUTH/BASE_URL, which is not an environment variable name — it parses as a division, so the generatedauth.tsfailed to compile withTS2304: Cannot find name 'BASE_URL'. The same three names were wrong in the generated.env.duck-auth, and in whatkeys generateandkeys rotatetell you to paste where.duck-auth migrate <dialect>defaulted to--prefix=AUTH/and emittedCREATE TABLE IF NOT EXISTS AUTH/identities, which no dialect will parse. The prefix isauth_again, which is also what the drizzle schemas name their tables.duck-auth doctorlooked forsrc/AUTH/auth.ts, andinitwritessrc/auth/auth.tsby default, soinitfollowed bydoctorcould never find whatinithad just written.AUTH_DEFAULT_EN_MESSAGESkeyed ten of its twelve error-code entries as'AUTH/LOCKED'and the like. The resolver falls back to the id it was handed, so every one of those lookups missed silently and rendered the raw code to an end user. The one key that survived isAUTH_INVALID_CREDENTIALS— the formatter quotes only keys that are not legal identifiers, so the quoting itself marks the damage.DEFAULT_MAGIC_LINK_CONFIG.callbackPathwas/AUTH/magic-link/callback, and the provider builds the emailed URL from it, so every magic link sent at default config pointed at an upper-cased path.- The vue, solid, react and vanilla clients named
@gentleduck/AUTH/client/*in the errors they throw, and vue's injection key wasSymbol.for('@gentleduck/AUTH/client/vue').
Seventy more occurrences were test titles and failure messages naming codes like AUTH/STALE_WRITE,
which means grepping for an error code did not find the test that covers it.
Fix the scaffold's imports, which named symbols that no longer exist.
Separately from the rename, both init flavors still named the pre-a209418d symbols — AuthMemoryAdapter,
AuthCookieTransport, AuthScryptHasher, AuthJwtTransport, AuthArgon2idHasher — after that commit
stripped the Auth prefixes. They also imported from @gentleduck/auth/providers/password, which the
exports map does not have (it is passwords), named RedisSessionStore and RedisIdempotencyStore,
which have never existed in any form, took RedisLimiter from adapters/redis rather than
limiters/redis, and passed env: 'production' to new AuthEngine, which takes no such option. Both
templates now type-check: tsc on the generated output reports nothing but the unresolved imports that
--noResolve guarantees.
README.md's examples were pinned against the exports map in an earlier round; that guard now runs over
the init output too, which is the same kind of string and rotted the same way. The CLI's own tests had
asserted toContain('AuthMemoryAdapter') and toContain('RedisSessionStore') — an assertion that a
name is present can never notice that the name resolves to nothing.
Create the scaffolded env file owner-only.
init wrote .env.duck-auth at the umask default, typically 0644, and the file exists to hold
DUCK_AUTH_HS256_SECRET. It is created 0600 now; mode is masked by the umask, so this can only
narrow. It also reported scaffolded <path> for an existing env file it had deliberately left alone,
and now says kept existing.
Worth a decision, not changed here: DEFAULT_MAGIC_LINK_CONFIG.callbackPath is /auth/magic-link/callback
once the case is repaired, but the only magic-link route any adapter mounts is mountHono's
/auth/magic-link/verify. The default link lands on a route that exists nowhere. Changing a public
default is a behaviour change, so the case fix is all that is applied.
Read the GitHub account's verified address, so link-if-verified can fire for GitHub at all.
The GitHub provider requested the user:email scope and then never called /user/emails. It took the
address from /user, which carries only the public profile email — unset by default, and carrying no
verification claim either way — and it never set emailVerified. resolveFederationConflict requires
profile.emailVerified === true to link, so a host that configured onFederationConflict: 'link-if-verified' got a policy that rejected every GitHub sign-in it was ever asked to arbitrate. It
failed closed, which is why nothing caught it: a declared feature wired to nothing.
fetchProfile now reads /user/emails and takes the row that is both primary and verified, which is
the only thing that justifies setting emailVerified: true. Verification is read from GitHub, never
assumed. This is best effort by design: a token whose scopes were narrowed, a rate limit, or an account
with no verified address all fall back to the /user email carried without a verification claim, which
is the posture the Microsoft provider already takes. Sign-in is never failed over it.
New on OAuthClient: authedJson(url, accessToken, providerId), an authenticated JSON GET under the same
64KB body cap as userinfo, for any provider whose profile needs a second call. It refuses a non-http(s)
url before reaching fetch, because a host's own fetchProfile receives the client and can call it.
The provider had no test file at all — it was the lowest-covered module in the package at 16.66% of statements. It now has nine, covering both endpoints, both fallbacks, and both federation outcomes.
Worth a decision, not changed here: Sign in with Apple cannot complete a flow, at three layers. Apple
requires response_mode=form_post whenever any scope is requested, and begin() emits scope=name+email
with no response_mode, so Apple answers invalid_request. The only oauth callback any adapter mounts is
a GET reading query params, so Apple's POST would 405. And the __Host-duck-oauth binding cookie is
SameSite=Lax, which a browser withholds on a cross-site POST, so the state binding check would fail with
the cookie absent. The last of those is the decision: Apple needs SameSite=None on that cookie, and
Lax is load-bearing CSRF defence for the other five providers. Full write-up in
docs/superpowers/notes/2026-09-19-apple-cannot-complete-a-flow.md.
Related and also unchanged: OAuthClient.buildAuthorizeUrl accepts extraParams and no caller anywhere
passes it — it is the hook Apple's response_mode needs. The apple provider requests the name scope,
which Apple only ever delivers in the form_post body it cannot currently receive. The microsoft provider
requests User.Read while reading only the OIDC userinfo endpoint that openid/profile/email cover;
that one needs checking against a live tenant before anything is removed, since it may be what forces the
access token's audience to Graph.
Declare the five optional integrations that are imported at runtime and were in no dependency field.
@aws-sdk/client-ses, @opentelemetry/api, resend, twilio and web-push are each loaded with
await import('name' as string). The as string is deliberate — it stops the bundler resolving them, so
an app that never sends SMS never pays for twilio. It also stopped anything noticing they were declared
nowhere: no version range was expressed for any of them, and each one's own failure message told the user
to install a "peerDep" that did not exist. The sibling tell is exact: @aws-sdk/client-kms is loaded the
same way and is declared >=3 <4 optional; @aws-sdk/client-ses is loaded the same way and was not.
All five are now peerDependencies with peerDependenciesMeta.optional, matching the ten that were
already there. @aws-sdk/client-ses takes >=3 <4 from its sibling and @opentelemetry/api takes
>=1 <2; the ranges for resend, twilio and web-push are lower bounds read off the single symbol
each call site uses, so narrow them if you mean to support a specific major. Nothing is forced on a
consumer: an optional peer that is absent still fails the same way, with the same message.
src/__tests__/lazy-peer-deps.test.ts now pins it from both ends — every dynamically imported bare
specifier must be a declared optional peer, and every package name an error message tells the user to
install must be a real key of peerDependencies. The two halves found the same five independently.
Stop mountHono documenting a CORS middleware it does not mount.
mountHono's JSDoc read "opts.cors mounts a scoped CORS middleware". Nothing reads opts.cors, and
MountHono.App declares only get and post, so no middleware can be mounted through that type at all —
the option is not merely unimplemented, it is unimplementable as typed. A host setting
cors: { origins: [...] } got silence. It fails closed, so the consequence is a broken cross-origin
integration rather than an open one. Both comments now say it is inert; the option itself should be
implemented or removed, which is a decision, so it is left in place.
Related comments that named a dependency field the package does not have: hono and @grpc/grpc-js were
both described as peerDeps and are in no dependency field, because both adapters are structurally typed
and neither package is ever imported. The gRPC one also called it "lazily loaded"; there is no import.
Pin what opts.skip actually removes.
mountHono's skip had no test. It is also not the clean partition its names suggest: 'totp' gates
every MFA route, backup-code regeneration included, so a host that offers backup codes but not TOTP cannot
ask for that. Documented on SkipGroup and pinned in hono-skip-groups.test.ts, which asserts each group
removes exactly its own routes and that sign-in, sign-out, session and provider-begin survive every
combination. Worth a decision, not changed here: whether that grouping should split.
Also added: src/server/__tests__/adapter-options-wired.test.ts, which fails when a field is added to any
adapter's *Options type and read by nothing — the shape cors had.
Make strict({ env: 'production' }) refuse a forgeable signing secret.
The production gate checked the limiter, the stores, the idempotency store, cookie secure, the baseUrl
scheme, that a provider exists and that someone listens for lockout — seven checks, none of which looked
at the secrets those sessions are signed with. A deployment with a one-character HS256 signing key and a
one-character oauth stateSigningSecret passed every one of them. strict() is the call a host makes to
be told what is wrong before it serves traffic, so fixing the six things it did report and shipping was
the documented path to a green gate on a deployment whose session tokens anyone could mint.
RFC 7518 section 3.2 requires an HMAC key at least as long as the hash it feeds, so 32 bytes is the floor
for HS256. AuthJwtTransport and the oauth provider each compute one boolean at construction —
__weakSigningKey, __weakStateSecret — and strict() reads them by brand, the same way it already
reads __isNoopLimiter and __isMemoryStore. The brands are booleans the holder derived; neither carries
the secret, and neither is on the public type. A transport or provider that publishes no brand is not
checked, there being no way to read a foreign implementation's key. Providers are read through
engine.providers.list() rather than cfg.providers, so one supplied as a factory is checked too.
Empty is separated from short, because they are different failures. An absent stateSigningSecret is not
an unsigned state — createHmac accepts an empty key and produces a perfectly valid MAC with it, so the
state is signed with a key every attacker also has. That is refused at construction, in every environment,
rather than recorded for a strict() call the host may never make. A short-but-present secret is a
production policy question, so it is reported by strict() and only in production, matching every other
check there.
The CLI's --production scaffold has claimed since it was written that this call "refuses to start on a
weak secret, an insecure cookie, or a missing limiter". Two of those three were true. The comment is now
accurate as written, and .env.duck-auth already tells operators to use 32 bytes.
Six tests in src/core/__tests__/strict.test.ts cover it, including that key length is silent outside
production. Each check was mutation-proven in its own run, the floor value included.
Refuse a batch unlink that would lock an account out, and emit the event that says one happened.
identities.unlink refuses to drop the last way into an account: the remaining provider links plus the
live credentials must not come to zero. identities.unlinkMany called the store directly and applied no
such check, so the batch path — the one a bulk admin action takes — could leave an identity with no
provider and no credential, reachable by nobody. flows.unlinkProvider refuses the same thing twice,
before and after the write, with a rollback; the facet is simply the weaker of the two implementations.
identity.unlinked was declared in the event catalogue as "the mirror of identity.linked", with a
SECURITY note calling it "the half an account takeover performs, dropping the real owner's login so only
the attacker's route is left". flows.unlinkProvider emitted it. The facet did not, on either method, so
a host subscribing to it for alerting saw nothing when the write came through auth.identities.
Both are fixed by delegation rather than by a second copy of the check: unlinkMany now calls unlink
per row and linkMany calls link, both through the refusable() wrapper that already turns a refusal
into a skipped row rather than an aborted batch. A duplicated guard is what let these drift in the first
place. linkMany inherits the duplicate-providerId refusal the same way. allowedLockout is always
false from this facet, which is accurate: the override is flows.unlinkProvider's, and this path has
none — a host that means to strand an account still has that route.
A batch unlink that previously stranded an account now skips that row and returns without it. The returned array is what it always was: the rows actually written.
Record who ran a batch erasure, and stop a bulk import swallowing driver failures.
identities.erase takes { reason, operatorId } and binds the actor so the store stamps who performed
the erasure. identities.eraseMany took a bare array of ids and bound nothing, so the irreversible
operation was attributable on the single path and anonymous on the batch. It now takes the same envelope
and binds the actor once around the one adapter call; omitting operatorId still leaves an outer
request-scoped actor alone, because withActor(undefined) is a fence that clears the scope rather than a
no-op. eraseMany(ids) is now eraseMany(ids, { reason }) — a required argument, matching erase.
The Identities.Store contract is unchanged: adapters carry attribution through the actor scope.
bulkCreate caught every error from a row and counted it as failed. This file has refusable() for
exactly this distinction, and its comment says why: Postgres leaves a transaction aborted once a statement
has failed, so swallowing one makes a later COMMIT a silent ROLLBACK. A bulk import that lost its
connection returned { created: 0, skipped: 0, failed: N } and threw nothing. A refusal this layer decided
is still a failed row; a driver failure now propagates.
bulkCreate in merge mode also linked through the store rather than through link, so a provider folded
into an existing account emitted no identity.linked. It goes through link now, which is also where the
duplicate-providerId refusal lives, so the inline re-check is gone.
Worth a decision, not changed here: bulkCreate in replace mode erases the account it is replacing
through the store, with no reason and no operatorId, where erase and eraseMany both require one.
An ambient actor still flows through, so attribution is possible; it is never explicit.
Spending an MFA backup code now reaches the event bus. recovery.mfa.escalated was declared on
Events.EventMap, listed in AUDITED_EVENTS so the audit envelope would be stamped onto it, and allowed
through the webhook event filter — and nothing ever emitted it. flows.completeStepUp accepts
method: 'backup-code', rotates the session to aal: 2 and records backup-code on its factor list, and
that session row was the only trace: no webhook fired and no audit subscriber could see that an account's
second factor had been satisfied without the factor. MfaImpl.verifyBackupCode emits it now, after the
compare-and-set claim and the revoke, so a subscriber reading the row it names finds the code already
burnt and revoked, and a losing racer emits nothing.
Breaking, at the type level only: the payload's ticketId is now credentialId, carrying the backup-code
row that was spent. duck-auth has no ticket concept — ticketId appeared nowhere but its own declaration —
and credentialId is what mfa.ts already calls this identifier for webauthn enrolment and in the
suspicious payload. Nothing emitted or subscribed to the event, so any handler written against the old
name was unreachable code.
Worth a decision, not changed here: BackupCodesFacet.verify, the second backup-code implementation, still
emits nothing. It is wired by the host rather than mounted on AuthEngine and holds no event bus, so giving
it one changes a published constructor signature — a required parameter breaks direct constructors, an
optional one puts the silent gap back as a configuration footgun.
The client_credentials grant is rate-limited, and reachable. M2MImpl.exchange compares a
caller-supplied client_secret against stored material through the same ApiKeysFacet.verify that
ApiKeyProvider.complete uses. That provider guards the call twice — a type-and-length check so a
non-string cannot throw a TypeError past the quota, then limiter.consume — and exchange did neither,
leaving an unbounded online guessing oracle for service-account secrets.
M2MImpl now takes a Limiter.Me as its fourth constructor argument, ahead of the optional config, and
m2m() takes one in the same position. The quota is keyed on clientId, not on the presented secret: a
brute force sends a different secret every attempt, so keying on the secret hands every guess a fresh
budget and bounds nothing. clientId and clientSecret are now required to be strings of at most 512
characters, checked above the limiter so an unbounded id cannot become an unbounded limiter key. This is a
breaking constructor change for anyone constructing M2MImpl directly — which, until this release, was
nobody outside the package, because of the next paragraph.
@gentleduck/auth/core exported type { M2m } and no runtime symbol, so the grant could not be reached:
a host could name M2m.ExchangeInput and had no way to obtain anything that accepts one, while the class
docstring told them to mount a /oauth/token route calling exchange(). M2MImpl, m2m and
DEFAULT_M2M_CONFIG are now exported from ./core. The root export surface is unchanged.
An impersonation can name the authorization that allowed it. identity.impersonated has always
declared an iamDecisionId, documented as "the one entry an audit log cannot afford to be missing", and
the field appeared exactly once in the package — in that declaration. No option carried it, the only
emitter never set it, nothing read it. Flows.ImpersonateOptions now takes iamDecisionId, validated to
1–256 characters like the reason beside it, and it reaches the event. An impersonation that names no
decision publishes no key at all rather than an explicit undefined.
Not changed here, and worth a decision: flows.impersonate still accepts an arbitrary authorize
callback, so the declared AUTH_IMPERSONATE_REQUIRES_IAM is thrown by nothing and an impersonation with no
IAM decision behind it remains possible. Requiring one is a breaking policy change.
Also reported, not changed: OAuth.StatePayload.nonce is documented "one-time use" and nothing enforces
it. The nonce is minted, signed, round-tripped and shape-checked, but never recorded or compared, and
AUTH_OAUTH_NONCE_REPLAY is declared for the violation and raised by nothing. DPoPVerifier already
enforces the same property through a nonce store, and both a memory and a Redis store ship, so the
machinery exists — but the oauth provider holds no store, and adding an optional one would put the silence
back as a configuration footgun.
FakeRedis now expires keys of every type, not only strings. The in-process redis used by every unit
test of the redis-backed stores — and exported publicly on the ./test subpath — held its TTLs on string
entries, so EXPIRE on a set or a sorted set answered 0 and set nothing. RedisSessionImpl keeps its
per-identity session index in a set and bounds it with one expire call; against this fake that call did
nothing, so the index was unbounded in every test that has ever run. TTLs now live in one map keyed by the
key, a bare SET drops a prior TTL as real Redis does without KEEPTTL, DEL clears it, INCRBY keeps it
so a rate-limit window survives its own increments, SCAN walks sorted sets too, and every set and
sorted-set read consults expiry first.
Passkey registration can request direct attestation, and strict() now checks that it does.
Passkey.Options gains attestationType ('none' by default); the registration call hardcoded 'none',
so the fips compliance preset's webauthnAttestationDirect requirement was unreachable on that path
while mfa's WebAuthn enrollment honoured the same option. strict() now reads the setting off the
registered passkey provider instead of taking the operator's word, and observed evidence takes precedence
over a supplied attestation — a deployment can no longer claim limiterRequired while holding the Noop
limiter.
The sqlite adapter waits for pragma foreign_keys = on before answering. It was issued fire-and-forget
in the constructor. A raw driver sets it synchronously through exec first, but the adapter also accepts a
drizzle handle you already have, and for an async one the first queries ran with foreign keys still off —
the state that leaves dangling provider links behind an erased identity. The pragma is now awaited at the
boundary every facet calls through, and a pragma that fails surfaces as an adapter error instead of an
unhandled rejection on an unenforced connection.
RedisEvents reports a fan-out it could not perform. A rejected publish was answered with () => 0,
so emit resolved as success while no other node in the fleet received the event and nothing recorded it.
The failure is now logged with the event name, and emit still resolves — local handlers have already run,
so a redis blip does not become a failed sign-in.
The session stores with no schema now enforce the constraints the SQL ones do. assertSessionAllowed
carried three of the seven CHECK constraints every dialect declares, so memory, redis and valkey accepted
a session id that was not a 64-character hash, a deadline preceding the row's own creation, a rotation
preceding it, and an absolute cap below the sliding expiry. The first of those let a caller use the bearer
token itself as the primary key; the last left the session with no absolute cap at all. The shared store
compliance suite defaulted session ids to a raw label that every SQL adapter overrode away, so it had been
asserting a different contract on either side of the divide it exists to erase — it now hashes by default.
strict({ env: 'production' }) now refuses the in-process limiter. It rejected the in-process stores by
brand but checked only AuthNoopLimiter for the limiter, so a deployment supplying AuthMemoryLimiter — or
relying on the engine's fallback to it — passed the production gate holding a class whose own docstring reads
"Dev/test only". Across a fleet that grants every brute-force budget once per node, and a restart returns all
of them. It also reached the compliance evidence, which positively reported limiterRequired satisfied for
it and, since engine evidence takes precedence, overrode an operator attesting otherwise. Breaking for any
production deployment currently passing AuthMemoryLimiter or omitting a limiter: strict() will now refuse
to boot. Wire AuthRedisLimiter, or your own. The in-process events bus, passkey challenge store and DPoP
nonce store remain unchecked and are reported in the audit log rather than changed here.
An expired session is refused even when the cleanup write fails. getBySid, touch and resolveBySid
delete the dead row before throwing AUTH_SESSION_REVOKED, and that delete was awaited unguarded — so a store
that refused the write returned AUTH_ADAPTER_FAILED in the refusal's place. That code is not in ABSENT, so
the resolveSession(...).orNull() every server adapter calls rethrew it and a merely-expired session came back
as a 500 instead of a 401. The cleanup can no longer change an answer that was decided before it ran.
A signup flow token now expires. beginSignUp writes two deadlines — the credential's 24-hour absolute
cap and flow.expiresAt, the sliding thirty-minute window every advanceSignUp pushes forward — and only the
read path checked the first, while nothing anywhere compared the second against the clock. advanceSignUp and
completeSignUp therefore accepted a flow token at any age, and completeSignUp mints a session, marks the
address verified and merges the staged profile. Nothing swept the row either, the credential store contract
having no gc, so an abandoned signup's token stayed a valid account takeover indefinitely. The three entry
points now share one liveness check, and each still answers with its own error code.
The in-process identity store now enforces the identity constraints every dialect declares. Both logins
must be a non-empty string and fit their column (320 / 191), and neither half of a provider link may be
blank — the five chk_auth_identities_* and chk_auth_identity_providers_* constraints pg, mysql and sqlite
all carry, and which the memory store and the identities facet checked nowhere. A blank, missing, non-string
or over-long username/email was written in dev and refused on the first production write, and such a row
is not findable by the address it was meant to carry, since the uniqueness pass reads a blank login as
absent. Breaking for a host whose profileToIdentityProfile can yield a blank address — the shape most
of them are written in — which now fails at the store rather than silently in dev only.
A session that timed out now says so. AUTH_SESSION_EXPIRED — declared with an { expiredAt } meta and
translated in i18n — was raised by nothing; every deadline threw AUTH_SESSION_REVOKED, which is also what
a store answers for a row that is absent, corrupt or another tenant's, and whose only detail is a free-text
reason. getBySid, touch, resolveBySid and the JWT transport's exp check now raise the expiry code
and name the instant, so a caller can tell "sign in again" from "this session was revoked". The code joins
the absent set in the same change, so resolveSession(...).orNull() still reads a timed-out session as
absence rather than an error. Breaking for a caller matching on AUTH_SESSION_REVOKED to detect a timeout
— match AUTH_SESSION_EXPIRED, or both. The store layer is unchanged. A further ten declared error codes are
raised by nothing at all; they are listed in the audit log rather than changed here.
A stateless transport's verdict now survives the store fallthrough. resolveSession verifies the token
with the transport and, where that does not vouch for it, falls back to looking the token up as a session id
— which is what lets one engine accept both a cookie sid and a bearer JWT. That fallback also ran for a token
the transport had authenticated and then refused, and since the store is keyed by sid hash it can hold no row
for a minted token, so its no session for that sid overwrote the real answer: an expired JWT reached the
caller as AUTH_SESSION_REVOKED, never as AUTH_SESSION_EXPIRED, through resolveSession and through
CompositeTransport alike. AUTH_SESSION_EXPIRED is the one refusal a transport reaches only about a token
whose signature verified — every "not a token of mine" rejection is AUTH_SESSION_REVOKED — so it is now
kept and answered, and every other refusal still falls through exactly as before. Breaking for a caller
matching AUTH_SESSION_REVOKED to detect an expired bearer token, which is the same move as the change
above and now reaches the path server adapters actually take.
strict() now rejects the two dev-only implementations NODE_ENV was the only thing guarding.
MemoryIdempotency and AuthNullCaptchaVerifier each refuse to construct under NODE_ENV=production, but
strict({ env: 'production' }) is a separate declaration and most runtimes leave NODE_ENV unset, so on
those deployments nothing refused them. strict() covered only half of its own stated intent — it caught an
omitted idempotency store, not the same class wired by name, which is what the README example does — and
never looked at the captcha verifier at all. Both are now rejected by brand, the way the memory adapter and
the in-process limiter already are, and IdempotencyImpl republishes its store's brand so the gate reads it
without reaching into private state. An always-pass captcha removes protection from every path it fronts; the
unconfigured verifier, which refuses every challenge, is still accepted, because failing closed is not a
footgun. Breaking for a production deployment that was wiring either one — which is the point: it was
unprotected and silent about it.
duck-auth doctor now runs. The command imports your auth.ts and calls strict() on its auth
export — with no arguments, though strict(opts) reads opts.compliance before anything else. Every
invocation died on a TypeError that the surrounding catch reported as
strict() rejected: Cannot read properties of undefined (reading 'compliance'), so a healthy config and a
broken one were equally "rejected" and no check ever ran. It now asks about production, the only env whose
checks exist; prints meta.detail, which is where the list of failed checks lives, rather than
AuthError.message, which is the bare code AUTH_MISCONFIGURED; and says could not run strict() for a
failure that is not a verdict, so a crash can never again read as a finding.
strict() now checks every store you hand it, not three of them. The production sweep for in-memory
stores was a hand-written list of identities, sessions and credentials, under a comment reading "over
every store". Engine.Stores has a fourth slot: orgs. The memory adapter's org store carries the same
__isMemoryStore brand the sweep looks for, so a deployment with real identity, session and credential
stores beside adapter.orgs passed the gate while its membership rows and role grants — an authorization
input — lived in one process, invisible to the rest of the fleet and emptied by a restart. The sweep now
reads the stores bag itself, so orgs is covered and a slot added later cannot be missed. Org.Store is a
read interface over your own tables, so a production host is expected to have one already; the memory store
is the dev stand-in. Breaking for a production deployment wiring adapter.orgs — wire your own org
store, or leave orgs unset if the app has no org concept.
The memory device-fingerprint store now refuses a bound it cannot apply.
AuthMemoryDeviceFingerprintStore caps how many device fingerprints it remembers per identity and expires
them after a TTL, and applied both as a bare > against the configured number. Every comparison against
NaN is false, so a non-finite maxPerIdentity or ttlMs -- Number(process.env.FP_MAX) on an unset
variable, say -- did not widen the bound, it removed it: one identity retained 500 fingerprints where the
default caps at 50, and no sighting ever expired. Zero and negative failed the other way, remembering
nothing, so every request read as a new device and stepped up at the detector's default score. Both are now
validated in the constructor and throw AUTH_MISCONFIGURED, matching deviceFingerprintDetector and
authImpossibleTravelDetector, which already validated their own numeric config. Breaking only for a
config that was already not working: the value that now throws was previously disabling the bound it was
meant to set.
The SSRF guard now refuses internal host names, not only internal addresses. isBlockedHostname
recognised four spellings of localhost and nothing else, so metadata.google.internal -- the name GCP's
metadata server answers on, and what an SSRF payload targeting GCP actually uses -- passed the guard that
already refuses the 169.254.169.254 behind it. Also now refused: metadata.goog, anything under the
ICANN-reserved .internal, and the localhost4 / localhost6 / ip6-localhost / ip6-loopback aliases
that ship in the /etc/hosts of Debian-family images. Applies to every outbound URL the library checks:
webhook endpoints, captcha verify endpoints and web-push subscription endpoints. Breaking for a
deployment that points any of those at a .internal host -- which was already the case for .local and
every RFC 1918 address, so this extends an existing posture rather than introducing one.
Outbound oauth and captcha requests no longer follow redirects. OAuthClient's code exchange, refresh,
userinfo and authedJson calls, and the captcha siteverify POST, all left fetch on its default
redirect-following behaviour, so the request did not necessarily land on the endpoint the scheme and SSRF
guards had checked. A 307 or 308 from a token endpoint re-posts the body with client_secret in it to
whatever the Location names, and a redirect to http:// puts that secret -- or the bearer token on the
userinfo call -- on the wire in plaintext. All five now pass redirect: 'error', the posture the webhook
dispatcher already took. Breaking for a provider whose token, userinfo or captcha endpoint answers with a
redirect: point the config at the final URL instead.
The Redis/Valkey event bus no longer goes deaf to the fleet when a handler is swapped. RedisEvents.on()
recorded a channel teardown by deleting its _subscriptions entry synchronously and issuing the
UNSUBSCRIBE several awaits later. Because the shipped valkey adapter unsubscribes a channel rather than a
callback, an on() for the same event in that window -- unsub(); bus.on(event, next), the ordinary handler
swap -- opened a second subscription that the in-flight teardown then cancelled, and the map was left holding
a live-looking entry so no later on() resubscribed. The node kept delivering its own local emits and kept
reporting a listener to strict(), while receiving nothing from other nodes for lockout, session.revoked
or suspicious. The teardown now stays in the map while it runs and resubscribes if a handler arrived
meanwhile. Not breaking: same public surface, and a bus that never swaps handlers behaves as before.
CSRF: bearer clients are no longer refused by the NestJS auth guard, which now also takes a cfg.
verifyCsrf only applied its documented bearer exemption when a caller passed isBearer, and the helper
that detects one is module-private -- so csrfGuard was the only caller that ever set it. NestJS'
makeGuard calls verifyCsrf directly (to reuse the session Nest middleware already resolved) and passed
neither isBearer nor a Csrf.Cfg, so a client authenticating with Authorization: Bearer <session token>
was refused AUTH_CSRF on every mutating route, and a configured allowedOrigins / headerName was
ignored where the CSRF-only guard beside it honoured them. verifyCsrf now derives the flag from the
Authorization header when it is not told, and makeGuard accepts cfg. An explicit isBearer still wins.
The exemption itself is unchanged -- still only when no cookie rides along, still held to the origin checks
otherwise. Behaviour change for a NestJS app using makeGuard with bearer clients: those requests now
pass the CSRF check instead of being rejected, matching every other adapter.
WebhookDeliverer now refuses a maxAttempts or timeoutMs that would stop delivery silently.
maxAttempts was clamped rather than validated, and Math.min(Math.max(1, NaN), 20) is NaN, so a
non-finite value made the retry ladder's while (attempt < maxAttempts) false on the first pass: no request
was made for any event and the delivery was dead-lettered with attempts: 0 and an empty lastError.
timeoutMs was not checked at all, and setTimeout floors a negative or non-finite delay to zero and
overflows past 2^31-1 to zero too, aborting every request before it left. Both now throw
AUTH_MISCONFIGURED at construction, as backoffMs beside them already did. The clamp on a finite
maxAttempts is unchanged. Breaking for a deployment passing a non-finite maxAttempts (including
Infinity, which the clamp silently turned into 20) or a timeoutMs outside [1, 2147483647] -- those
configurations were not delivering webhooks, and now say so at boot.
Every store's gc(now) now refuses a cutoff that is not a finite timestamp. The nine implementations
across memory, SQLite, Postgres, MySQL and Redis each compared rows against now directly and disagreed
about a number that is not one: NaN swept nothing and reported deleted: 0 on memory, SQLite and Redis
while Postgres threw, and Infinity deleted every session on memory and Redis while SQLite swept
nothing. The silent half is the likely one -- Number(process.env.GRACE_MS) is NaN when the variable is
unset -- and for identities it meant the hard-delete that makes a soft delete a delete never ran, while
reporting success. All nine now throw AUTH_INVALID_PARAMETERS, and the store-compliance matrix asserts it
on all six dialects along with the fact that nothing was swept before the refusal. auth.sessions.gc() and
auth.identities.gc() are unaffected -- they take no argument and pass Date.now() themselves. Breaking
only for a caller passing a non-finite cutoff straight to a store, which was not collecting anything, or
was collecting everything.
A TOTP enrollment can no longer be started over a confirmed one. mfa.beginTotpEnrollment deleted every
totp credential for the identity before minting the pending one, so starting an enrollment removed the
second factor. The mounted POST /auth/mfa/totp/begin route asks only for a signed-in session, which made it
the unguarded way to do what /auth/mfa/totp/remove demands a step-up for -- and a better one, since it also
returned a secret the caller controls and confirmTotpEnrollment then regenerated the backup codes. It was
invisible as well: removeTotp is what emits mfa.removed, so the deletion reached no audit log. It now
throws AUTH_MFA_REQUIRED when a live confirmed enrollment exists; re-enrolling goes through removeTotp,
which is guarded. Replacing a pending enrollment still works as before.
Regenerating the MFA backup codes now requires a step-up. POST /auth/mfa/backup-codes/regenerate took
any signed-in session and answered with ten fresh plaintext backup codes, each of which completeStepUp
accepts as a second factor -- so a caller holding only the password reached AAL2 in one round trip, and the
victim's codes were destroyed to mint them. The route now refuses with AUTH_STEP_UP_REQUIRED unless the
session is already AAL2, the same gate /auth/mfa/totp/remove uses.
The last-factor lockout guard now agrees with itself. flows.unlinkProvider and identities.unlink each
carried their own idea of what still authenticates an identity, and each allowed a lockout the other
refused: the first counted an expired password or passkey as live, the second counted one-shot recovery
tokens and second factors such as totp as standing ways in. Both now use isStandingFactor -- live,
unexpired, and of a kind that can start a session on its own (password, passkey, api-key).
SAML: the email/nameID agreement check now reads the assertion, not the SP config. It fired whenever
allowedNameIdFormats merely listed emailAddress, so an SP accepting persistent alongside it refused
every persistent login -- an opaque nameID never equals an email. It now keys on the format the assertion
states, falling back to the previous behaviour when the IdP omits one. An emailAddress assertion whose
email disagrees with its nameID is still refused.
anomalyFacet now refuses a threshold or timeout that is not a usable number. threshold, stepUpAt
and denyAt must be finite and within 0..1, and detectorTimeoutMs finite and between 1 and 2^31-1. All
four were taken on trust while both shipped detectors validated their own config, and every way they could
be wrong failed open: score >= NaN never denies and never steps up, and a delay setTimeout cannot use
abandons every detector before it answers, leaving evaluate reporting no signals at all.
Channel sends silently stopped happening on a misconfigured retry ladder. ChannelGuard — which every
shipped channel (smtp, resend, ses, twilio, webpush) constructs from its own public Cfg —
applied retries and timeoutMs without validating either. A non-finite or negative retries made
tries <= this._retries false on the first test, so the provider was never called and the guard threw
undefined; Infinity turned the doubling backoff into a tight loop, since setTimeout overflows past
2^31-1 and floors to ~1ms. A non-finite timeoutMs skipped the <= 0 branch that disables the deadline
and reached setTimeout anyway, aborting every send before it left. Password resets, magic links, email
verification and account-deletion confirmations stopped being delivered, with nothing an error mapper could
render. Both are now refused at construction with AUTH_MISCONFIGURED: retries finite in 0..10,
timeoutMs finite in 0..2147483647. Both documented zeroes stay legal. This is the sibling of the webhook
deliverer ladder fixed earlier in this changeset — same shape, second implementation.
Both rate limiters could be configured into not limiting at all. AuthMemoryLimiter and RedisLimiter
validated the weight and key passed to consume, but applied max and windowMs from config
unchecked. Measured: max: NaN produced zero refusals in fifty consumes on one key in both limiters —
count > NaN is false forever — so the brute-force defence that strict({ env: 'production' }) refuses to
boot without was silently not running. windowMs: NaN or 0 is the inverse: the bucket never elapses, so
the first budget spent is the last and resetAt reads Invalid Date. Both are now refused at construction
with AUTH_MISCONFIGURED — max finite in 1..Number.MAX_SAFE_INTEGER, windowMs finite in
1..8640000000000. Breaking for anyone passing Infinity: an unlimited limiter is a contradiction, and
NoopLimiter is the declared way to have none — unlike Infinity, strict() can see and refuse it.
A DPoP proof from any point in time was accepted when the freshness window was misconfigured. The RFC
9449 4.2 check is Math.abs(now - iat) > clockSkewMs + freshnessMs, and > against a non-numeric sum is
false, so the check never fired. Measured: with clockSkewMs: NaN, real ES256 proofs dated one year old,
ten years old, and one year in the future were all accepted — while a default verifier correctly refuses a
one-hour-old proof. Freshness is the primary limit on replaying a captured proof, and the default jti
store is per-process, so a fleet has no second line. DPoPVerifier now refuses clockSkewMs or
freshnessMs that is not a number between 0 and 3600000, with AUTH_MISCONFIGURED.
A caller could switch off session-hijack and anomaly checks by omitting one header, on the Next.js and
gRPC adapters. requestSecurity treated "the host supplied no getCaller" and "this request carried no
fingerprint" as the same early return, and every adapter collapsed the two at the call site with
?? {}. nextCaller and grpcCaller deliberately resolve no IP — a Web Request has no peer, and the
library will not read a forwarded header — so on those two adapters the User-Agent is the entire
fingerprint, and dropping it skipped the hijack evaluation, the suspicious audit record and the anomaly
snapshot alike. Hijack.Cfg.onMissingSignal: 'strict' exists for exactly that move and was unreachable,
because the return happened before the policy was consulted. Adapters now omit caller entirely when no
getCaller is configured, and requestSecurity only skips on that. Behaviour is unchanged on the default
onMissingSignal: 'soften' except that the drift is now recorded; hosts on 'strict' get the reaction
they configured. A session that recorded no fingerprint is unaffected.
The default password hasher reported a broken install as a wrong password, and threw on a corrupt hash.
Argon2idHasher.verify returned argon.verify(...) without awaiting it, so its catch was unreachable: a
malformed PHC string rejected out of a method documented to answer false, which also let a corrupted
credential row distinguish itself from a wrong password despite the uniform-timing construction around it.
The same catch, once reachable, swallowed the AUTH_MISCONFIGURED that loadArgon2 builds to carry the
@node-rs/argon2 install command — and since DEFAULT_PASSWORDS_CONFIG selects Argon2id while that package
is an optional peer dependency, a deployment that loses the native module told every user "invalid
credentials" and the operator nothing. The module load now sits outside the catch and the verify is
awaited inside it. A hash from another algorithm still answers false without loading anything.
strict() no longer refuses every HIPAA and FIPS deployment. The AAL-floor gate asked
providers.list() whether an mfa provider was registered. list() is the sign-in grid and keeps only
capabilities exposing begin/complete; MfaImpl exposes enroll/verify, so it was registered and
never listed. Both presets that raise the floor above AAL 1 therefore threw AUTH_MISCONFIGURED saying no
mfa provider was registered while one was, in every environment. It now asks providers.has('mfa').
The FIPS hasher requirement is now verified rather than attested. fips declares
fipsValidatedHasher, meaning "Argon2id with FIPS params", and ARGON2ID_COMPLIANCE — those exact params,
exported by the package — was referenced by nothing: a fips deployment running scrypt booted strict() on
the operator typing true. Argon2idHasher and ScryptHasher now publish whether they meet that
parameter set, and strict() reads it off the registered password provider. A hasher that publishes no
answer is still attested for, since FIPS 140 approves no Argon2 and a host may hold a validated
implementation of its own. Also: Passwords.Cfg.hasher was documented as defaulting to scrypt; it defaults
to Argon2id.
Backup codes and trusted-device tokens now refuse a length that is not a secret. mfa({ backupCodeLen: 0 }) minted ten codes that were all the literal -, and verifyBackupCode accepted - for any identity;
a count that was not a positive whole number deleted an identity's codes and minted none. The same knob was
unchecked on BackupCodesFacet (byteLength, count) and RememberMeFacet (byteLength, ttlMs). All
three constructors now throw AUTH_MISCONFIGURED for a value outside the usable range, the way
toApiKeysCfg already did for randomBytes. Breaking for a deployment that configured a shorter code
than the new floors: backupCodeCount/count 1-64, backupCodeLen 8-64, byteLength 5-64, remember-me
byteLength 16-128.
Both password hashers now bound their own parameters. scryptHasher({ keylen: 0 }) and
({ saltLen: 0 }) each wrote a row that parse refuses, so sign-up succeeded and the correct password
never verified again; ({ N: 2 }), ({ r: 0 }) and argon2idHasher({ memoryCost: 8, timeCost: 1 }) were
accepted end to end, with needsRehash calling each result current. The constructors now throw
AUTH_MISCONFIGURED for a structurally invalid set — including an N too large for the maxmem both
calls pass, which previously threw inside Node on the first sign-up — and strict() refuses a hasher below
its own defaults in production only, so a test suite can still use a cheap KDF. A hasher of your own is not
judged. Breaking for a deployment configured below keylen 16, saltLen 8, hashLength 16 or
saltLength 8, or running production below N 2^14 / r 8 / keylen 32 / memoryCost 19456 /
timeCost 2.
An elapsed expiresAt now refuses a credential everywhere, not only on API keys. ApiKeyImpl.verify
already treated an expired row as a revoked one, and isStandingFactor — which both lockout guards use to
count the ways back into an account — already refused an expired password, passkey or api-key. Seven
gates read revokedAt alone: the password sign-in and rehash paths, the passkey allowCredentials offer
and the branch that mints the session, remaining() and verify on BackupCodesFacet, and
MfaImpl.verifyBackupCode. A password carrying a rotation deadline signed in for ever after it passed,
while the engine simultaneously refused to unlink that account's last provider on the grounds it would
become unreachable. beginPasskeyRegistration's excludeCredentials is unchanged, so re-enrolling an
expired authenticator is still the way back. Breaking for any deployment that writes expiresAt on a
credential through the store contract and relies on it being ignored.
CookieTransport's CSRF companion cookie now follows the session cookie's settings instead of four
hardcoded literals. { domain: '.example.com' } — the option that exists for cross-subdomain deployments
— used to emit the session cookie for the whole domain and the companion as __Host-duck-csrf for the
issuing host alone, so a page on a sibling subdomain could not read the token, could not send
x-csrf-token, and had every state-changing request refused AUTH_CSRF with nothing said. sameSite and
path diverged the same way, and over plain http the __Host- prefix had the browser discard the cookie
entirely — previously documented as a WARN rather than fixed. The prefix is now kept exactly when its
three conditions hold, revoke() clears under the attributes it set, and a csrfCookieName getter
exposes the result for the client's existing option of that name. Breaking: the companion is named
duck-csrf when domain is set, path is not /, or secure is false; the default is unchanged.
DPoPVerifier now holds a proof's jti until that proof stops being acceptable, rather than for
clockSkewMs + freshnessMs. The freshness check is two-sided, so a proof is live for twice that span, and
the old TTL covered only the half after iat — measured at the defaults, the verifier asked for a 90s TTL
on a proof that stayed fresh for another 179s, and the identical proof replayed successfully once its
jti had aged out. The gap is how far ahead the client's clock runs, which is what clockSkewMs exists
to tolerate. NonceStore implementers should honour the ttlMs they are handed per key rather than
assuming one global window.
WebhookDeliverer now stamps each delivery attempt as it is sent. Every attempt on the retry ladder used
to carry the first one's timestamp, so a retry arrived bearing its position on the ladder as an age, and
verifyWebhookSignature refuses a stamp past its tolerance — 5 minutes by default — with a bare false,
the same answer as a wrong secret. The defaults stay inside the window, but backoffMs: 60_000 puts
attempt 5 at 7.5 minutes and the documented maxAttempts: 20 puts attempt 20 a day and a half out, and
both are validated as supported. The body keeps the event time so a delivery's attempts stay
byte-identical for idempotency on deliveryId; read freshness from the X-Duck-Timestamp header, which
is what the verifier takes.
Email addresses are now folded — trimmed, lowercased and NFC-normalised — when an identity is written,
rather than left for each dialect's unique index to fold on read. SQLite's lower() is ASCII-only, and
its create leans on that index alone, so an address carrying one uppercase non-ascii letter took a row
that nothing could find afterwards: measured, find by the exact address used to create the account
answered AUTH_IDENTITY_NOT_FOUND immediately after creating it, and a second account on the same address
was accepted. Sign-in by email, password reset and duplicate detection all missed the same way. Trimming
closes the same hole for a padded address on every dialect. Breaking, mildly: profile.email now
comes back trimmed and lowercased; existing rows are untouched and stay reachable, since lookups still try
the stored spelling alongside the canonical one.
isSessionFresh now bounds the freshness window at both ends, as JwtTransport.verify and checkStepUp
already did. A rotatedAt ahead of the clock gave a negative age, which is under any window, so a session
stamped a day out read as permanently fresh — and freshness is what gates a password change. Nothing
bounds that column from above and the stamp is written by whichever node rotated the session, so one
machine with a fast clock handed the rest of the fleet sessions that never went stale. Ordinary node skew
inside the window still reads fresh.
IdempotencyImpl.handle now releases the key when the executor throws. The claim was written with the
response TTL — a day by default — and nothing let go of it, so a client retrying under the same key after
an error read a miss, lost the claim, polled out and was answered 409 idempotency-conflict for the next
24 hours, with the work never running again. That is the one case an Idempotency-Key exists for. A
response the executor did return is still cached and still not re-executed; a process that dies
mid-executor still falls back to the TTL.
MfaImpl now treats an elapsed expiresAt as revocation, as every other credential kind already did.
Six readers - both TOTP paths, both WebAuthn-MFA paths and the two has* probes - filtered on revokedAt
alone, so a deadline written on a totp or webauthn-mfa row was honoured by nothing: the second factor
kept verifying past it, the assertion challenge kept offering the credential, hasTotp kept reporting the
enrolment to beginPasswordReset, and the enrolment guard kept refusing to replace it. A credential whose
deadline has not passed, or that has none, behaves exactly as before.
AuthEngine.strict() now rejects AuthConsoleChannel, AuthNoopChannel and AuthTestChannel in
production, as it already rejects the memory adapter, the in-process idempotency store and the
always-pass captcha verifier. Each of the three refused itself on NODE_ENV and nothing else, and that
check does not fire where NODE_ENV is unset - so a deploy could log every magic link to stdout, or
report every password-reset email as delivered without sending one, and pass strict(). The three now
publish a brand strict() reads off the channels bag; a real channel is untouched, and all three stay
accepted under env: 'development' and 'test'.
IdentitiesImpl.exportAll now narrows its session list to the tenant context it is given, as it already
narrowed the credential list one line above. The GDPR right-to-access blob read every session the identity
had anywhere, so in a multi-tenant deployment one tenant's export named the other tenant's sessions and
carried their tenantId, IP, user-agent, fingerprint, assurance level and factor list. Identities are
global while sessions are tenant-scoped, which is what makes the unfiltered read cross a boundary. A caller
that passes no tenant, which is every single-tenant deployment, gets exactly the same export as before.
5.12.0
Minor Changes
- Return signatures: the engine answers with the row or rejects, and a caller who wants absence as a value asks for it.
The engine answers with the row or rejects
Every public facet method used to answer null for two different things — the row was
not there, and the store could not be reached. A caller could not tell them apart, which
is how a sign-up reads a timed-out lookup as a free email address.
Each of these now rejects with an absence code instead, and .orNull() restores the old
shape wherever the old shape was wanted:
identities:getById,getByEmail,getByProviderSub,softDelete,restore,erase→AUTH_IDENTITY_NOT_FOUNDsessions:getBySid,touch,revoke,revokeByHash→AUTH_SESSION_REVOKEDorgs:get→AUTH_ORG_NOT_FOUND;resolveMembership,removeMember,setRoles→AUTH_MEMBERSHIP_NOT_FOUNDflows.getSignUpFlow,apiKeys.revoke, the remember-me facet'sverifyAuthEngine.resolveSession→AUTH_SESSION_REVOKED, with the reason onmeta.reason
Every one of these returns Answer.Me<T>, a promise carrying three readers: orNull(),
orDefault(fallback) and wrap(). orNull and orDefault swallow only the absence
codes in the exported ABSENT set; everything else still throws. wrap() never rejects
and hands back { data, error }.
resolveSession also gained a code that is deliberately outside ABSENT:
AUTH_SESSION_IDENTITY_ERASED, for a live session whose identity no longer resolves. It
is a data-integrity violation rather than a sign-out, so .orNull() does not eat it — it
stays loud at every call site, including all sixteen framework adapters.
A refused argument is no longer a missing row
An empty, oversized or non-string sid, and the other malformed arguments a caller holds,
now reject AUTH_INVALID_PARAMETERS rather than reporting as absence. orNull()
deliberately does not swallow it: a bug in the calling code should not read as "no such
session". An untrusted inbound token is different — that is a verdict, and stays
AUTH_SESSION_REVOKED.
Writing an adapter no longer means producing Answers
The adapter layer is internal and plain: throw, or return the value. Removed from
@gentleduck/auth/adapters: Adapter.Wrapped, Adapter.Answer and Adapter.Result.
Adapter.Me now holds the store contracts directly rather than wrapped copies of them.
Failed<C> is no longer exported from @gentleduck/auth/core/errors.
A store contract answers a row or throws. Absence is the store's own code — for instance
Credential.Store.findById rejects AUTH_CREDENTIAL_NOT_FOUND where it used to answer
null — and the facet in front of it is where absence becomes a value again. The list
forms are unchanged: zero rows is a list, not an absence.
Pluggable store contracts reject a miss
If you implement one of these yourself, it breaks at compile time. No adapter shipped in this repo is affected; these are the host-supplied ones.
Idempotency.Store.get→Promise<CachedResponse>, rejectingAUTH_IDEMPOTENCY_MISS(new code) for a key never seen, an elapsed TTL, a row that no longer parses, **and a key still holding the tombstoneclaim()wrote**. All four must reject, or the claim/poll protocol serves a status-0 placeholder to a racing caller as if it were a real response.Operations.Store.load→Promise<State>, rejectingAUTH_OPERATION_NOT_FOUND(new code) when nothing has been persisted yet. A store that is down must reject its own code:hydrate()runs at boot, and reading an unreachable store as "nothing is switched on" brings a node back serving traffic through a maintenance freeze.Passkey.ChallengeStore.take→Promise<string>, rejectingAUTH_CREDENTIAL_NOT_FOUNDfor a key never put, an elapsed TTL, and a challenge already consumed.
Both new codes join ABSENT, so orNull() reads them back as null.
Verifiers and the budget guard
Transport.ITransport.verify and revoke lost their | null. This is a compile-time
break only: a stateless verifier that cannot vouch for a token is stating a verdict, so it
throws rather than shrugging.
ChannelGuard.spend now returns Answer.Me<void> and rejects AUTH_RATE_LIMITED once the
send budget is spent, instead of answering a prose string. This changes what the five
channels put in SendResult.error: it is now the code "AUTH_RATE_LIMITED" rather than
"smtp: send budget exhausted, next at <iso>". The reset time is not lost — it moves to
meta.retryAfter as whole seconds, floored at 1, which is the machine-readable form a retry
actually wants. retryable: true, which is what callers branch on, is unchanged.
Two capabilities became throwing getters
AuthEngine.orgs and the transaction facade's orgs were OrgsImpl | null properties. Both
are now getters that throw AUTH_PROVIDER_NOT_REGISTERED when no org store was configured,
matching how passwords, mfa and apiKeys already behaved. The message names stores.orgs
rather than a provider, because there is no orgsProvider() to add.
Single-use tokens can no longer be redeemed twice
flows.signIn with a magic-link token, flows.completePasswordReset, flows.verifyEmail,
flows.completeAccountDeletion and the OAuth refresh exchange all claimed their credential row with
a compare-and-set and revoked or deleted it in a second statement. The claim rotated the
secret to the value it already held, so between the two writes the row was still findable by the
token's hash and still unrevoked — only its version had moved. A second redemption landing in
that window read the live row at the next version, won its own compare-and-set, and went through.
For a magic link that is two sessions from one emailed link. For a password reset it is a second reset: the loser's password was the one left on the account, set after the winner had already revoked every session. Two people holding one reset link both got to use it, and the later one won.
It needed concurrent requests to hit, which a shared inbox, a mail scanner prefetching links, or a double-click all produce.
For OAuth refresh the window was wider still — it spanned the whole outbound token exchange, and a second presentation got a valid token set of its own instead of tripping the reuse detection that RFC 6749 section 10.4 is built on.
Email verification and account deletion are the mild pair: the second run re-verifies a verified
address, or re-deletes a deleted account. Account deletion already came out with the right answer,
but by accident — the loser was stopped a line later by softDelete reporting an already-hidden row
as a miss, not by its own claim. It also minted and mailed a second cancellation link on the way.
flows.completeSignUp had the same outcome by a different route: it never took a compare-and-set at
all. It read the flow row, ran the whole tail — profile merge, email-verified flag, session issue —
and revoked the row at the end, unconditionally. The window was the entire flow, and two concurrent
completions each walked away with a session of their own.
All six now spend the token in a claiming write, rotating the secret off the presented hash so the
row stops matching it the instant the winner lands. A racer that read before the claim still loses on
the version; one that reads after no longer finds a usable row. The four recovery flows answer
AUTH_RECOVERY_TOKEN_INVALID and sign-up answers AUTH_SIGNUP_TOKEN_INVALID, as before.
OAuth rotates to a derived hash rather than a random one, so the claimed row stays findable and a replayed refresh token still revokes its whole family. Two notes for OAuth callers:
- A refresh whose provider exchange throws releases its claim, so the refresh token keeps working — the same as before this change.
- After a successful refresh, the old refresh token's hash no longer resolves to a credential row at
all. Presenting the old token behaves exactly as it did:
AUTH_OAUTH_REUSE_DETECTED, with the family revoked.
flows.cancelAccountDeletion given a token was already exclusive — it deletes the undo row before it
restores, and that delete refuses a row another cancellation has taken — but the loser came back with
AUTH_CREDENTIAL_NOT_FOUND. That code means "no row" to orNull and orDefault, so a caller
reading absence saw a lost race as "there is no such token". It now answers
AUTH_RECOVERY_TOKEN_INVALID, the same as every other refusal on that path. No account was ever
wrongly restored or left deleted; only the reported code changes.
mfa.backupCodes.verify had the same defect and is fixed the same way. It listed an identity's
codes, matched one that was not revoked, and revoked it in a second, unconditional statement, so two
verifications arriving together both matched the same live row and both returned true — a
single-use recovery code was reusable. The match is made against the stored secret, so burning it in
the claiming write closes the window outright.
mfa.verifyTotp was reusable for the same reason, against a docstring promising single use within
the window per NIST SP 800-63B. It read the last accepted step off the credential's metadata,
compared, and recorded the new step in a second, unconditional write, so two verifications arriving
together both compared against the same stale step and both returned true — one code, two
step-ups. Burning the secret is not available here, because a TOTP secret is shared with the
authenticator app and has to survive, so the comparison and the record became a single conditional
write and the loser returns false. mfa.confirmTotpEnrollment is deliberately left unconditional:
two concurrent confirmations agreeing on the same outcome is the right answer.
If you ship a custom credential store, this is the one thing to check.
Credential.Store.patchMetadata takes an optional fourth argument, expectedVersion. Given one it
is a compare-and-set: a row whose version has already moved rejects AUTH_STALE_WRITE and nothing
is written. Omitting it behaves exactly as before, so existing stores keep compiling and every other
caller is unaffected — the hazard is a store that accepts the argument and ignores it, which loses
the guard without failing anything. runCredentialStoreCompliance from @gentleduck/auth/test now
covers it, so run it against your store. One detail it pins: a conditional UPDATE cannot tell a
missing row from a stale one, so when expectedVersion is given an unknown id also answers
AUTH_STALE_WRITE; without it, AUTH_CREDENTIAL_NOT_FOUND, as before. All four bundled adapters —
memory, SQLite, Postgres and MySQL — honour it.
One behaviour change worth knowing: because the token is now spent before the work rather than
after it, a completion that throws part-way leaves the link used up. flows.verifyEmail,
flows.completeAccountDeletion and flows.completeSignUp could previously be retried with the same
link after a failed profile or identity write; now the user needs a fresh one. That is the trade
being made — the alternative is the double execution above — and magic-link and password reset
already behaved this way.
Nothing to do on upgrade unless you implement Credential.Store yourself, where patchMetadata's
new optional argument is the one thing to honour.
WebAuthn clone detection now works on the MFA path, and survives concurrency on both
The signature counter is the whole of WebAuthn L2 section 6.1.3 clone detection: an authenticator that has been copied shows up as an assertion whose count no longer exceeds the stored one.
mfa.verifyWebauthnMfa never stored it. It read the count, ran the comparison and returned without
writing, so the stored value stayed at whatever enrollment recorded — zero for a synced or platform
authenticator — for the life of the credential. Against a stored zero the comparison can never fire,
and against a hardware key registered at N every count above N passed forever. A cloned
authenticator was not detected on this path at all, with no timing needed. It is now recorded on
every accepted assertion, so the comparison measures against the last one rather than against
enrollment.
passkey.complete did store it, but in a second, unconditional write. Two assertions arriving
together both read the same stored count, both cleared it and both got in — the clone waved through
for arriving alongside the real authenticator instead of after it. The comparison and the record are
now a single conditional write on both paths; a lost race means another assertion has already moved
the count past this one, so it reports as the rollback it is. passkey.complete throws
AUTH_PASSKEY_MISMATCH with a passkey-counter-rollback signal, and mfa.verifyWebauthnMfa returns
false with webauthn-mfa-counter-rollback, exactly as each already did for a sequential rollback.
Authenticators that do not count are unaffected: a reported count of zero — which every iCloud Keychain passkey sends on every login — still short-circuits before the write, so nothing is stored and nothing is refused.
A SAML assertion with no ID no longer skips the replay store
saml.complete consumes the assertion id through the replayStore you supply, which is what stops a
captured response being posted twice. It only ran when the assertion carried an ID, and an assertion
without one fell through the check and signed in — so a deployment that had explicitly wired replay
protection had none for a body shaped to omit it, and nothing reported the guard had been skipped.
SAML 2.0 core requires ID on an assertion, so its absence means the assertion is malformed rather
than that there is nothing to check.
An assertion with no usable ID is now refused whenever a replayStore is configured, through the
same redacted refusal as any other bad assertion: the operator audit gets the reason, the caller gets
AUTH_PROVIDER_FAILED. Blank and whitespace ids are refused with it — used as a key, '' would be
consumed once and then lock out every later assertion that also lacked one. Ids longer than 256
characters are refused before they reach your store.
If you do not configure a replayStore, nothing changes. If you do, and your IdP genuinely omits
assertion ids, sign-ins that previously succeeded will now be refused — that is the bug being fixed,
and the fix is an IdP that emits conformant assertions.
An unreadable impersonation window no longer reads as "not an impersonation"
session.actingAs is the envelope that makes a session an impersonation: the real operator, the
reason, and when the window closes. null means the session was never one, so resolveSession skips
the expiry check — correctly, since there is no window to close.
The drizzle adapters answered null for a second case: a column that was present but could not be
read. A session that was an impersonation then loaded as an ordinary session belonging to the person
being impersonated — the operator's expiry cap gone, and the audit envelope that events.audit.ts and
actor.request.ts use to name the real operator gone with it, so the trail said the user did it.
All three dialects now refuse such a row with AUTH_SESSION_REVOKED, which is what the redis store
already did for the identical input. An absent column still reads as null, because that is what an
empty column means. factors still degrades to [] when unreadable — that can only lower the AAL a
session claims, where an unreadable window only removes a restriction.
The same applies when the column holds bytes that are not JSON at all. fromJsonColumn used to answer
null for an unparseable string, which made a corrupt column indistinguishable from an empty one
before either parser saw it; it now hands the string through, so the parsers decide. This mattered
most on sqlite, where every JSON column is raw TEXT.
You will see this only if an acting_as column has been corrupted or hand-written in a shape the
library does not produce. If you have such rows, they now fail the read rather than loading
under-restricted.
An unsafe redirect is now refused for the whole response, not just its own intent
executeIntents (the Web-Fetch executor in server/generic) checks every redirect intent with
isSafeRedirectUrl and answers AUTH_MISCONFIGURED when one fails. The check ran, and the unsafe
Location was never set — but the refusal only broke out of its switch, so the loop carried on and
the next intent reassigned the status and body. A following json intent turned the 500 into a 200
success, and a following safe redirect turned it into a 302 that actually redirected. The express
executor returns at that point, so the two disagreed on the same intent list.
The refusal now ends the response in both, with any cookies already staged still sent, which is what
express did all along. If you write providers that emit a redirect intent followed by more intents
and rely on those later intents running after a rejected URL, they now stop — but a rejected URL
already meant the redirect config was wrong.
A bearer header no longer exempts a cookie-carrying request from CSRF
csrfGuard and verifyCsrf skipped every CSRF check for a request carrying Authorization: Bearer,
on the reasoning that a header credential is not ambient and so cannot be forged cross-site. Neither
checked whether the bearer was what authenticated the request. A cross-site request carrying the
victim's session cookie and any Authorization: Bearer value — the token never had to be valid —
took the exemption, authenticated by cookie, and skipped the check, including the Origin /
Sec-Fetch-Site gate that is the actual cross-site defence. Reaching it needs a CORS policy that
allows the Authorization header with credentials, which is a common one.
The exemption now applies only when the request carries no cookie, which is the condition its own reasoning always assumed. A request with a cookie is held to the origin checks whatever header it adds, and still skips the double-submit token when it is genuinely bearer-authed.
Nothing changes for an API client that sends no cookie, including a browser client on another origin
holding its token in memory. If you have a browser client that sends both a session cookie and a bearer
token cross-site, those requests are now refused with AUTH_CSRF; send the x-csrf-token header, or
stop sending the cookie.
Redaction no longer hands the secret through
Two functions on egress paths stripped secrets for some inputs and not others.
redactSecrets returned a whole unwalked subtree once it passed its depth cap, so a payload nested
more than eight levels deep carried its secrets out in full while the value read as redacted. It is the
default redact for WebhookDeliverer, so that subtree went out in an HTTP POST to the endpoint you
configured. Past the cap it now answers '[depth-cap]', which is what scrubMeta next door already
did. A consequence: a self-referencing payload used to stay circular through redaction, fail
JSON.stringify and dead-letter; the cycle is now cut at the cap, so it delivers truncated instead.
redactProviderError matched api_key=x but not "api_key":"x" — the quote sits where it expected the
separator — so the JSON body an HTTP SDK throws went through unredacted, as did
Authorization: Bearer <token> and any credential a provider puts in the URL path (Telegram's bot
token, Twilio's account SID). It now handles quoted labels, auth schemes, and opaque path segments,
while keeping route names like /v1/send so a failure still says which call it was.
If you pass your own redact to WebhookDeliverer, nothing here changes it. If you relied on reading
a credential back out of a channel's error string, you no longer can.
Encryption at rest: a kid that silently destroyed data, and unchecked GCM parameters
AuthAesGcmDataAtRest writes <alg>$<kid>$<iv>$<tag>$<ct>, and took the operator's kid verbatim.
A kid containing $ encrypted without complaint and then split into six parts on the way back, so
every read of everything written under it failed as malformed — the write path never said a word, and
the damage was only visible once the data was already unrecoverable. A kid is now refused at
construction if it is empty or contains $, for the current key and for every previousKeys entry,
which is where a rotation would otherwise smuggle one in. AuthKmsEnvelopeDataAtRest already
percent-encoded its key id for exactly this reason.
AuthKmsEnvelopeDataAtRest did not check its IV or auth-tag length before handing them to Node.
Node accepts an AES-GCM tag shorter than 128 bits — it only emits a deprecation warning — so a
forgery against a truncated tag was a 2^32 problem rather than a 2^128 one, on the column whose
entire job is to hold when an attacker can already write to the database. It now requires a 12-byte
IV and a 16-byte tag, as AuthAesGcmDataAtRest next door already did, and zeroes the unwrapped DEK
on those refusal paths too.
If you configured a kid containing $, your adapter now throws AUTH_MISCONFIGURED at startup
instead of at every read. Anything already written under such a kid was never readable.
Federated sign-in no longer invents an email verification nobody made
onFederationConflict: 'link-if-verified' hands an existing account to whoever signs in with its
address, on the strength of one flag. Two providers set that flag without a provider having said so.
Microsoft marked every address verified because one was present. Entra's email is mutable, set
per-tenant, and documented by Microsoft as unverified — their own guidance is never to use it for
authorization — and the provider's default tenant: 'common' accepts a sign-in from any Entra
tenant, including one the attacker created. Under 'link-if-verified' that was a cross-tenant account
takeover: set email on your own tenant's user to the victim's address and sign in. The email is now
carried but never asserted as verified, which is what every other provider here already did — they key
on an explicit claim, and Microsoft sends none.
Apple read an absent email_verified as true. Apple sends the claim whenever it sends an email,
in either the boolean or the string spelling, so this only ever covered the case where Apple said
nothing — and it resolved that case the dangerous way. Now only a claim of true counts.
If you use 'link-if-verified' with Microsoft, linking by email stops. That is the fix, not a
regression: nothing had verified the address. Link from your own onFederationConflict callback if you
have proved domain ownership out of band.
Sign in with Apple could not begin a flow at all
APPLE_ENDPOINTS spelled "Apple has no userinfo endpoint" as userinfoEndpoint: ''. The client
validates every endpoint it is given as an http(s) URL, and an empty string is a string, so
buildAuthorizeUrl threw AUTH_MISCONFIGURED before a redirect was ever produced — every Apple
sign-in, every time. The key is now absent, which is the spelling the validator and
OAuthClient.userinfo both already treated as "not configured". The provider's only test covered
decodeIdToken in isolation, so nothing exercised the flow; there is now an end-to-end one.
Documentation that promised more than the code did
OAuth.BeginInput.returnTo said "library appends to the front-end after callback". It does not:
complete answers with clearCookie + startSession, and authVerifyState is not exported from any
entrypoint, so the value is signed into the state, length-capped and then read by nobody. The doc now
says so, and records that routing it to a redirect intent later has to go through
isSafeCallbackPath first — an attacker who calls begin themselves gets a validly signed state for
any target they like.
authRefreshoauthToken — OAuth refresh-token rotation with reuse detection — is imported by nothing
and exported from no entrypoint, so no consumer can reach it, while complete still writes the
familyId and generation metadata it reads and AUTH_OAUTH_REUSE_DETECTED sits in the public error
catalogue. It is flagged in the source; wiring it up needs a ./providers/oauth entrypoint and is left
as a decision rather than taken here.
IdempotencyImpl.handle said it scoped by identity "so one caller's cached response is never replayed
to another". True only with an identityId. Without one every anonymous caller shares the '_anon'
bucket, which makes the Idempotency-Key the entire authorisation to read that response back. The
behaviour is unchanged and now has a test pinning it; the comment no longer claims otherwise.
A requireMfa intent is acted on rather than dropped
Provider.Intent's contract says FlowsImpl "consumes and strips" the internal startSession and
requireMfa signals before an adapter sees them. It only stripped requireMfa. A provider answering
startSession alongside requireMfa therefore got a plain session at the AAL it asked for, and the
demand for a second factor left with the filter; one arriving alone produced an outcome with no
session, no sid and no intents, which tells a caller nothing. signIn now throws
AUTH_STEP_UP_REQUIRED carrying the requested methods.
None of the six shipped providers emits one, so nothing changes for them. The provider list is open,
and this is what a custom provider's requireMfa always meant.
A failed plugin install no longer leaves its providers behind
PluginRegistry.install promised "all of the plugin or none of it" and registered providers first,
before the event handlers, the facet and the install hook. Providers has no unregister, so a hook
that threw left that plugin's sign-in providers registered and reachable through signIn for the life
of the engine — belonging to a plugin that is not installed, whose install never ran, and whose facet
and event subscriptions had just been rolled back around them. The existing test for a throwing hook
asserted the facet and the events and said nothing about the providers.
Providers now register last, after the hook, because that is the one step that cannot be undone and nothing that can throw may follow it. A colliding provider id is therefore reported after the hook has run rather than before; a hook's side effects were never rollback-able either way, and the plugin id stays free for the retry.
Session freshness was measured in one direction only
fresh is what privileged operations branch on, and both gates that compute it subtracted
rotatedAt from now without bounding the result below zero. A timestamp in the future yields a
negative age, which is under any window, forever — so JwtTransport.verify reported fresh: true
for the life of every token whose frsh was minted ahead of the verifier's clock, and
flows.checkStepUp never found such a session stale enough to re-challenge. frsh is written by
the issuing server and read by the verifying one, and nothing makes those the same clock: one
instance running ahead of its peers, or a single NTP step backwards, was enough to disable the
step-up freshness requirement silently and indefinitely.
Both now bound the age in both directions, the way authVerifyState already bounded its own iat.
Skew smaller than the freshness window still reads fresh, which is what a session issued a moment
ago needs.
DPoP: the jkt comparison is the caller's, and nothing said so
DPoPVerifier.verify proves the presenter holds the key carried in the proof — a key the
presenter chose. It cannot know which key the access token was issued against, so the returned
jkt is the entire binding. A caller who reads verify not throwing as "DPoP checked" gets none
of it: whoever steals an access token can mint a proof with their own keypair, and the ath,
htm, htu, iat, jti and signature checks all pass, because every one of them is a statement
about the request they are making and the token they are holding.
No behaviour changed — the verifier is correct and this is the protocol working as designed. What
was missing is that nothing told the caller the returned value was load-bearing: bindPayloadToDPoP
writes cnf.jkt and is called nowhere, no doc in the package mentioned jkt or cnf, and the
tests asserted only that a thumbprint came back. Verified.jkt now carries the requirement, the
class docstring no longer claims the binding unconditionally, and two tests pin what "did not
throw" is worth on its own.
SAML: a client that verifies no signature is refused at construction
samlProvider takes a pre-built @node-saml/node-saml client, and that client is the only thing in
the flow that checks a signature — nothing else in the wrapper can tell a forged assertion from a
real one. A client configured with wantAssertionsSigned: false and
wantAuthnResponseSigned: false accepts any XML that parses, from anyone who can reach the ACS URL,
and the wrapper had no opinion about it. It now refuses that pairing at construction, through the
same .options probe the callbackUrl check already used. Only the pairing: signing the assertion
alone is the common case and still passes, as does a client that exposes no options.
DEFAULT_SAML_CONFIG's comment claimed both flags were "on by default", which overstated what they
do — they are read only when generating SP metadata, a document handed to the IdP, and never reach
the verifier. The comment now says which is which.
wantAuthnResponseSigned did nothing at all
In the fallback metadata renderer it was resolved from config and then consumed by
${wantAuthnResponseSigned ? '' : ''} at the end of the document — empty in both branches, so
true and false produced byte-identical XML. The expression is gone. SPSSODescriptor has no
attribute for response signing, so there is nothing standard to emit in its place; the option is now
documented as inert rather than left looking wired, and is a candidate for removal in a major.
Also documented, not changed: an assertion carrying no NameID Format skips allowedNameIdFormats
entirely. SAML 2.0 §8.3 reads an absent one as unspecified, which the default allowlist does not
contain, so enforcing it would refuse every IdP that omits the attribute — and many do. The
signature still binds the assertion to the IdP, so this bounds IdP misconfiguration, not an attacker.
A TOTP secret that decodes to nothing verified anything
verifyTotp and matchTotpStep refused a secret that failed to decode, but not one that decoded to
zero bytes — '', or a string of only padding and spaces. Node builds a working HMAC from an empty
key, so such a row went on producing perfectly valid six-digit codes, derivable by anyone from the
clock alone, while hasTotp reported the account as protected and eligibleAal counted it toward
AAL 2. Anyone who could reach the step-up endpoint for that identity passed it.
Reaching it needs a row written with such a secret rather than one this library minted —
beginTotpEnrollment always generates 160 bits. The memory adapter refuses a secret that trims to
nothing, but that guard is the memory adapter's alone: the drizzle adapters have none, so on a SQL
deployment a plain empty string stores without complaint, and padding-only passes even the memory
guard. The check therefore belongs where every adapter and every host-written store passes through
it, which is the verifier, and that is where it now is.
Not enforced: RFC 4226 §4 requires at least 128 bits and this generates 160, but the floor is left unchecked because a deployment may hold shorter secrets imported from an authenticator that mints 80. Zero bytes is refused because no secret is legitimately that.
"Forget this device" deleted whichever credential you named
RememberMeFacet.revoke takes a credential id off a device-management request. It checked the row
belonged to the caller and then deleted it — with no check of what kind of credential it was. Pointed
at their own TOTP enrollment it removed their second factor; pointed at a passkey it removed that;
pointed at the password-reset token sitting in their inbox it cancelled that. All silently, and
without the events the real removal paths emit, so an account could lose its MFA through an endpoint
whose job was to forget a browser. Everything else that reads or deletes over the shared recovery
kind filters on metadata.purpose; this was the one that did not, and now does.
list and revokeAll also disagreed about what a trusted device is. list promised the live ones
and included any whose TTL had elapsed, so a user's device list showed entries that could no longer
skip MFA. revokeAll was built on list, which meant "wipe every trusted device" skipped exactly the
expired rows and left them in the store permanently. list now filters on expiry and revokeAll
sweeps the rows itself, expired ones included.
Backup codes: byteLength was a setting that did nothing
BackupCodesFacet.Cfg.byteLength was declared, defaulted and never read — the length was the literal
8 at the call site. Its sibling RememberMeFacet wires the identically named option, which is what
made the difference visible. An operator raising it to harden their recovery codes got the same 40
bits as before, silently. It is now wired: the alphabet carries five bits a character, so the default
of 5 bytes is the same 8 characters it always was, and 10 bytes is 16.
Two things that had to move with it. groupFour split once at position four, so anything longer than
eight characters came out as one group of four and one of everything else; it now groups every four.
And _normalize claimed in its docstring to de-hyphenate and instead added a hyphen, on a hardcoded
length of 8 — it now strips what the user typed and re-applies the canonical grouping, so where they
put the hyphens stops mattering. verify also gained an upper length bound; it had a floor but no
ceiling, so a multi-megabyte string was uppercased and sha-256'd on every attempt.
A cloned passkey announced itself and was let in
WebAuthn's signature counter is the only clone-detection signal a relying party gets, and Level 2
section 6.1.3 states the comparison over the pair: "if either authData.signCount or
credentialRecord.signCount is nonzero" and the new value is not greater, the authenticator may be
cloned. The check here read only the new value — newCounter !== 0 && newCounter <= oldCounter — which
excuses exactly one transition: an authenticator that had counted to five now reporting zero. That is a
count that went backwards, and it is the textbook clone signature. It is now the spec's condition, so
the case is refused and reported as passkey-counter-rollback like every other regression.
The zero that the old condition was protecting is a different authenticator: one that implements no counter reports zero at registration and forever after — every passkey synced through iCloud Keychain — so its stored counter is zero too, neither side is nonzero, and the comparison is skipped as it always was. Nothing about synced passkeys changes.
Three unit tests asserted the old behaviour, under a comment about synced passkeys that describes the
case they did not test: they seeded a stored counter of five. A fourth, in passkey.test.ts, is
commented "presents a counter of 0 against a stored counter of 5" and passes newCounter: 3. The
bundled @simplewebauthn/server has always refused this input itself, so no deployment on the real
verifier was letting a rollback through — it was throwing a raw Error instead, which is the next item.
The WebAuthn verifier's own failures left as 500s
verifyAuthenticationResponse and verifyRegistrationResponse signal every check of their own — bad
origin, wrong rpID, counter rollback, unparseable attestation — by throwing a plain Error. Nothing
between the passkey provider and the consumer's handler caught one, so it escaped unmapped, and the
counter message carries the stored count: Response counter value 0 was lower than expected 5. Both
call sites now answer AUTH_PASSKEY_MISMATCH, which is what every other refusal on those paths already
answered.
One consequence worth stating: when the bundled verifier performs the counter check itself it throws
before the provider's branch runs, so the refusal is correct but the passkey-counter-rollback event
is emitted only when a verifier module returns rather than throws.
A passkey's user-handle binding could be switched off by the request
response.userHandle binds the assertion to the identity the credential is stored against. It was read
as string from an unknown request body and handed to Buffer.from, which throws on a number or an
object; the decoder answered null for the throw, and the caller treated null as "no handle was
sent" and skipped the comparison. Sending userHandle: 1 therefore turned the check off. A handle that
is present and not a string is now refused. A handle that is absent is still absent, which the spec
allows for a non-discoverable credential.
The passkey verifier was handed the wrong credential id
verifyAuthenticationResponse was given credential.id as the storage row's uuid rather than the
WebAuthn credential id, which registration stores verbatim in secret and which _resolveAllowList
already reads from there. The library only echoes the value back today, so the right one and a uuid are
indistinguishable at runtime — which is how it survived. No behaviour changes; it stops being a latent
break if the library ever compares it to response.id.
A scrypt hash with its key field emptied verified every password
ScryptHasher.verify derives its candidate at the stored key's length. Node's scrypt answers an
empty buffer for a keylen of 0 rather than throwing, and timingSafeEqual calls two empty buffers
equal — so scrypt$16384$8$1$<salt>$ accepted anything, including the empty string, in constant time
and with nothing logged. A column narrowed by a migration or a half-finished write is enough to produce
such a row, and verify reads exclusively from storage. parse now refuses an empty key or salt, which
needsRehash reads as "rehash this", so the row is replaced the moment its owner signs in properly.
Argon2idHasher is unaffected: it hands the encoded string to @node-rs/argon2, which parses it
itself. The bug is in the hand-rolled encoding, and it is the default hasher.
A password parameter upgrade never rolled out
autoRehash is on by default and had no test anywhere in the package. It could not have worked on a
real store. verify fires an opportunistic rotate at the row to stamp lastUsedAt, which bumps the
version; rehash then read the row, spent the whole argon2 or scrypt hash, and compare-and-set on the
version it had read. The touch lands inside that window every time, so the rotation failed with
AUTH_STALE_WRITE — which the caller swallows on purpose, being opportunistic. rehash now hashes
before it reads, so it is not holding a version across the hasher's cost.
The memory adapter applies a rotate synchronously, which is why no existing test could see this; the new one wraps the store so its writes land a tick later, as a driver's do.
maxLength above 1024 locked people out of the password they had just set
complete capped the incoming password at a literal 1024 while set validated against the
configured maxLength — the comment above it even claimed the two matched. Raising the setting
therefore produced passwords that could be chosen and never used again. The entry point now reads the
setting. Lowering it was already consistent, verify having capped there all along.
Two rate-limit refusals answered a 500 instead of a 429
refuseRateLimited reads resetAt defensively, because Limiter.Me is the host's interface to
implement and one backed by redis — or by anything with a JSON hop that forgets to revive the Date —
hands back epoch milliseconds. Its docstring says every limiter guard goes through it. Two did not:
AuthApiKeyImpl.complete and ChannelGuard.spend each built the refusal by hand with a bare
resetAt.getTime(), which throws on a number. The guard whose only job is to answer
AUTH_RATE_LIMITED answered a TypeError instead, and the channel one carried a comment pointing at
the api-key line by number as the thing it was copying. Both now call the shared refusal, whose
events argument accepts null for a guard with no bus in reach — neither has a subject to name, the
token being unverified at that point and a send being bucketed by channel and tenant.
randomBytes below 16 is refused instead of minted
randomToken(0) answers '' without complaint, so apiKeyProvider({ randomBytes: 0 }) made every key
the facet ever minted the literal string ak_live_ — the prefix, which is a documented constant. One
or two bytes is the same problem wearing a number, and nothing in the library throttles the guessing:
the sign-in limiter buckets by the hash of the token presented, so an attacker trying a different key
each time never meets the same bucket twice. toApiKeysCfg now refuses anything that is not a whole
number of bytes at 16 or above, at wiring time rather than quietly raising it, so the mistake is
answered where it was made. Keys already issued are unaffected — verify compares a hash and never
reads a length — and the compliance presets, all of which are 32 or 48, still ratchet above it.
The memory limiter kept every key it had ever seen
Its bucket map was never pruned. The key is whatever a request can name — an email for the password
guard, an identity id elsewhere — so a flood of distinct addresses grew the map for as long as the
process lived, and nothing stopped that reaching production: strict() refuses the noop limiter, not
this one, whose docstring is the only thing saying "dev/test only". Elapsed buckets are now dropped
when the map crosses a threshold, amortised rather than swept on every new key.
The live set stays unbounded on purpose: evicting a bucket that has not expired hands its owner their budget back, which is the whole limit. A deployment facing that flood wants the Redis limiter, whose buckets expire in the store. Both points are now in the class docstring.
A password reset in a multi-tenant deployment locked the account out
completePasswordReset resolved the tenant from its input and passed it to every store call it made
— the token lookup, the compare-and-set that burns it, the revoke — except the last one, the write
that actually swaps the password. PasswordsImpl.set defaults its tenant context to {}, so the
omission type-checked.
An undefined tenant is not "this tenant": inTenant emits no filter at all, and create stamps
tenantId: null. So the delete inside set reached that identity's password row in every tenant,
and the replacement it wrote belonged to none of them — invisible to the tenant-scoped read that
sign-in does. Completing a reset locked the account out of the tenant that asked for it, took the
other tenants' passwords with it, and left another reset as the only move, which did the same thing
again.
Single-tenant deployments never saw this: with no tenant anywhere, the unscoped write is the only write there is.
A reset still ends sessions in every tenant, not just its own. That is the safe direction and it is left alone.
Orgs: what the tenant argument and the event bus actually do
Auditing the orgs facet for the same shape turned up three things worth stating plainly rather than changing, since none of them is a bug in shipped code:
Org.Store requires a TenantContext on all six methods, OrgsImpl defaults it to {} and threads
it through faithfully — but the only shipped store is the in-memory one, which ignores it on every
method, and neither Org.Me nor Org.Membership has a tenant field to scope by. Org.Store is a
read interface over the host's own tables, so the scoping is the host's; what the library owes them
is that the context arrives unchanged. That is now pinned by a test, because dropping it silently is
exactly what went wrong in the password reset above. Four orgs test files had no tenant coverage at
all.
OrgsImpl is handed the event bus and never emits. There is no org.* event in the bus's map, so
membership and role changes — setRoles grants privileges — leave no audit trail, where every
comparable operation (mfa.enrolled, identity.linked, authz.revoked) does. Adding them is a
change to the public event map rather than an audit fix, so it is called out here and left to a
deliberate one; a host needing the trail wraps the three mutators.
setRoles on a membership already marked leftAt succeeds on the memory store. The write is
unreadable — listMembers and resolveMembership skip left rows and re-adding overwrites the roles
wholesale — so it is documented on the method rather than guarded, the liveness rule belonging to
whichever store owns the table.
A misconfigured device-fingerprint detector was silent, not loud
deviceFingerprintDetector({ store }) — with neither compose nor authSha256 — built fine,
registered fine, appeared in list(), and then returned [] for every request it ever saw. Its
composer had nothing to hash with, so it answered null, and the detector skipped on null.
A detector that is switched off and a detector that finds nothing wrong are the same observation to
whoever reads the decision. The rest of this file is written against exactly that: an address the
guard cannot parse gets a shared bucket, a request that sends no User-Agent gets a shared bucket —
"rather than going quiet", as one of the tests is named. A wiring mistake was the one case that did
go quiet. It is now refused at construction with AUTH_MISCONFIGURED, the way the sibling
impossible-travel detector already refuses its own bad config.
The test that covered this asserted the silence as correct.
decide() did not normalise the scores it was handed
Signal.score is documented as "0..1 ... clamped into that range on intake", and evaluate clamps
every score a detector returns. decide() is the other intake — the same ladder, standalone, for
re-deciding on signals a caller kept — and it clamped nothing, so the noisy-or arithmetic ran on
numbers it is not defined over.
Above 1, 1 - score goes negative, and two negatives multiply back to a positive: the aggregate
falls as evidence is added. Two signals that each denied on their own came out a step-up together.
Below 0 is the dangerous direction — a score of -9 multiplies the remainder by ten, drives the
aggregate negative, and mutes every real signal beside it into an allow.
The clamp moved into the shared aggregate, so both entry points agree. evaluate was never exposed
to this, since it clamps on the way in from the detector; what this fixes is the public decide()
and the two paths disagreeing about what a score means.
The Redis event bus subscribed to every channel twice
on() subscribes to a channel once per event, guarded by _subscriptions.has(event) — but the map
was only written inside the subscribe promise's .then(). Two on() calls for one event in the same
tick both read an empty map and both subscribed, and registering several handlers for one event at
boot is the ordinary case, not an edge one.
The result: every remote message was dispatched twice to every local handler, and the second
subscribe's unsubscribe overwrote the first in the map, so one channel stayed open for the life of
the process with nothing able to close it. On the valkey adapter it was worse — it adds an ioredis
'message' listener per subscribe, and its own docstring states the invariant it relies on:
"RedisEvents.on() subscribes at most once per channel ... so it never needs concurrent listeners on
the same channel." That was the claim; this was the code. It is true now.
The subscription promise is recorded synchronously, so the guard reads what it is guarding, and unsubscribing resolves it first — which also closes a channel that was unsubscribed before its subscribe had landed.
RedisEvents and InMemoryEvents disagreed about what an emit is
InMemoryEvents.emit snapshots its handler set before dispatching, with a comment giving the reason:
a handler that subscribes or unsubscribes mid-emit must not reorder the dispatch or extend it.
RedisEvents._dispatchLocal iterated the live Set, and a for...of over a Set visits entries added
after the cursor.
So a handler that called on() for its own event ran inside the emit that triggered it, one that
re-subscribed itself never terminated, and one that unsubscribed a sibling cancelled a delivery that
had already been decided. Two implementations of one bus contract, differing in whichever one an app
happened to configure. RedisEvents now snapshots too.
Nothing bounded the step-up gate
flows.completeStepUp verified a TOTP or a backup code with no rate limit, no failure counter and no
lockout event. MfaFacet holds no limiter of its own, so every call site has to bring one, and this
one did not — a caller could ask forever and each refusal was indistinguishable from an ordinary bad
guess. A TOTP is six digits and matchTotpStep accepts a drift window, which is roughly three live
codes in a million: guessable in minutes at a modest request rate, by exactly the caller a second
factor exists for — one holding the password and not the phone.
completePasswordReset's MFA branch has had this guard all along, keyed recovery:password:mfa:*;
the primary step-up path never got one. It now consumes stepup:${identityId} once the session
resolves, so the refusal names a subject and grinding one account spends only its own budget, and
routes through refuseRateLimited like the other guards — AUTH_RATE_LIMITED with a retryAfter,
and a lockout event an operator can act on.
A password alone could delete the second factor
mountHono registers POST /auth/mfa/totp/remove, and it took any authenticated session. A session
that had only ever proved a password could therefore delete the TOTP factor that exists for exactly
the case where that password is stolen — no code to guess, one request. It is the only mounted route
that destroys a credential, and it was the least guarded.
It now requires the factor be proved before it may be removed: flows.checkStepUp(session, { aal: 2 }),
refusing with AUTH_STEP_UP_REQUIRED and the challenge when the session has not stepped up. A
stepped-up session removes it as before.
POST /auth/mfa/totp/verify was unbounded on the same router and answers { ok: false } with a 200
however often it is asked, which is a clean six-digit oracle. It now consumes the same
stepup:${identityId} bucket completeStepUp uses, so switching routes does not buy a second budget.
Backup codes could be minted with no entropy at all
MfaImpl._randomBackupCode was the only randomness in the package that did not come from
node:crypto. It allocated a zero-filled Uint8Array, then filled it only if
typeof globalThis.crypto?.getRandomValues === 'function' — and when that guard was false it carried
on with the zeros. Every byte then indexed the alphabet at 0, so all ten of an account's backup codes
came out aaaaa-aaaaa: the entire MFA recovery set reduced to one guessable string, with nothing
thrown and nothing logged.
The guard was there for portability the package does not need — ~/core/crypto, which mfa.ts
already imports, requires node:crypto outright, so there is no runtime where this package loads and
randomBytes does not. It now calls randomBytes directly and the branch is gone, so there is no
longer a path where the generator can return a constant.
A backup code was single-use only in the docstring
MfaImpl.verifyBackupCode matched a live row and then called revoke unconditionally. Two
verifications that both read before either wrote matched the same row and both answered true,
against a class docstring that calls these codes single-use. flows.completeStepUp with
method: 'backup-code' lands here, so one code bought two elevations.
This is the shape closed across eleven sites in the previous release —
BackupCodesFacet.verify was the seventh of them and carries the explanation. That sweep fixed the
facet and missed this second, independent copy of the same function, which is the one auth.mfa
calls. It now claims the row with a CAS burn before revoking it: the loser sees AUTH_STALE_WRITE and
answers false, like any other miss, and because the match is timingSafeEqual(secret, hash) the
burn also stops a reader arriving after the claim.
Known: BackupCodesFacet codes cannot satisfy a step-up
Not changed in this release, because fixing it means choosing a canonical encoding and that invalidates one side's stored codes. Recording it so it is not rediscovered as a mystery.
There are two backup-code implementations, both public, both writing kind: 'recovery' rows with
metadata.purpose: 'mfa-backup-code' for the same identity — and they hash incompatibly.
auth.mfa.regenerateBackupCodes mints 9e9am-gkapm and stores sha256(code.toLowerCase());
BackupCodesFacet.generate mints FJQ2-GR4B and stores the hash of the uppercase, de-hyphenated,
re-grouped form. Each one's verify returns false for the other's codes.
flows.completeStepUp({ method: 'backup-code' }) routes to auth.mfa.verifyBackupCode, and it is the
only path from a backup code to AAL 2. So a host that wires BackupCodesFacet — which is exported for
exactly that, and is the configurable one — gets codes no step-up will accept, and the user's valid
code is refused as though it were wrong. Until this is settled, use one producer with its own matching
verify, and do not mix them on one identity.
The otpauth QR code showed the escape instead of the issuer
buildOtpAuthUri ran encodeURIComponent over issuer and then passed the result to
URLSearchParams, which percent-encodes on its own. An issuer of Acme Corp therefore left as
issuer=Acme%2520Corp, and the authenticator app displayed the literal Acme%20Corp under every
enrolled account. The label in the path is built by hand and encoded once, correctly, so the two
disagreed — and the Key Uri Format requires the issuer parameter and the label prefix to be the same
string.
Invisible on the default issuer: 'duck-auth', which has nothing to escape, so no existing test saw
it. The parameter is now passed raw and URLSearchParams does the single encoding; the label is
unchanged.
Maintenance and read-only mode were unreachable
OperationsImpl is the two ambient deploy switches: maintenance(true) refuses new sign-in, sign-up
and refresh with AUTH_MAINTENANCE and a Retry-After while live sessions keep resolving, and
readOnly(true) raises AUTH_READONLY_MODE on every mutating route for a cutover or a freeze window.
It has 155 lines, two error codes, four events, a Store contract and two test suites. Nothing could
reach any of it.
operations() and OperationsImpl were value-exported from no barrel and no exports subpath — only
the Operations type shipped, so a host could name the state and never build one. The package did
not construct one either: OperationsImpl appeared nowhere outside its own index.ts. The suites pass
because they call new OperationsImpl() directly, which is the shape where a unit is proven to work
and nothing is proven to use it.
The factory and the class are now exported from @gentleduck/auth/core, as AuthWebhookDeliverer
already is — that is the sibling case, the other surface a host wires itself, and it was reachable.
The root barrel re-exports ./core as types only, so its pinned key list is unchanged.
Two docstrings claimed assertOperationsForRoute is "run by every server adapter before dispatch" and
that both switches are "reached by server adapters" through it. No adapter calls it; src/server/
does not mention operations at all, across all nine. Both now say so. The gate is still not wired
into any adapter — a host that wants these switches enforced has to call
assertOperationsForRoute(method, route) in its own middleware. Wiring it here is left out on
purpose: it needs a per-route decision about which routes carry { maintenance: true } (sign-out and
session resolution have to keep working during a window) across the fifty-eight handler factories the
nine adapters export, and that is a design call rather than an audit fix.
The README's import examples did not resolve
Twelve import specifiers across seven code blocks named a subpath the package does not export or a
symbol the module does not have, starting with the quickstart: createAuth was imported from
@gentleduck/auth/core/config and the password provider from @gentleduck/auth/providers/password,
neither of which is in the exports map, so the first example in the README failed at
module resolution. Its providers array also called password({ findIdentityByEmail, passwords }),
where the working shape is passwords({ hasher: new Argon2idHasher() }).
The rest: @gentleduck/auth/core/transport/dpop and the three @gentleduck/auth/captcha/* paths do
not exist (DPoP ships from core/transport, the verifiers from core as authTurnstileVerifier,
authHCaptchaVerifier, authRecaptchaV3Verifier); adapters/redis was shown exporting
RedisSessionStore, RedisIdempotencyStore, RedisLimiter and FakeRedis, of which the first two
exist nowhere and the last two live at limiters/redis and test; server/hono exports mountHono,
not mount; server/grpc exports withGrpc, not authGrpcService; and createAuthClient is
exported only by client/vanilla, while the React, Vue, Solid and Svelte barrels were all shown
importing it — each has its own API, and the React entry contradicted the comment on the line above it.
A markdown fence type-checks in no build, which is why every one of these survived. readme-imports.test.ts
now parses the README's imports and asserts each subpath is in the exports map and each symbol is
exported by the module behind it, so the examples fail with the code that renames an export.
gRPC had no way to switch the drift check on
Each of the seven HTTP adapters takes a getCaller, and passing one is what turns on auth.hijack
and the anomaly detectors for that request — without it the session's ip and userAgent are stamped
at sign-in and never looked at again. withGrpc took { required, headerName } and nothing else. It
never built a fingerprint, never passed a requestSnapshot to resolveSession, and never gave
withResolvedActor an onSession, so a gRPC deployment ran with drift detection off and no hook to
enable it. src/server/__tests__/request-security.test.ts covers the wiring for all seven and names
grpc nowhere, which is why it stayed that way.
withGrpc now takes the same getCaller and onHijack as its siblings, and a grpcCaller reads the
user agent off the call metadata. Opt-in exactly as they are: requestSecurity answers {} when no
caller is supplied, so a deployment that does not ask keeps the behaviour it had — the first of the
new tests pins that.
One sharp edge, pinned by a test rather than hidden: gRPC Metadata carries no address, so
grpcCaller offers only a user agent. A session that recorded an ip during an HTTP sign-in reads as
stripped ip drift on every later gRPC call, which the default onMissingSignal: 'soften' reports as
suspicious and answers rotate. A host that can resolve the peer should pass its own getCaller;
that is why the hook takes the whole call.
Smaller corrections
CookieTransport.Cfg.maxAgeSecsaid the cookie was capped byabsoluteExpiresAt; it is capped byexpiresAt, the sliding deadline each rotation reissues against.CookieTransport.Cfg.securenow records that the companion__Host-duck-csrfcookie isSecureregardless, because the prefix requires it — so over plain http the browser drops it, keeps the session cookie, and the double-submit check fails closed on every write with nothing said.CompositeTransport.extractnarrows its first token instead of asserting it.limiters/__tests__/memory-limiter.test.tscontained onlyRedisLimitertests and is now namedredis-limiter.test.ts; the memory limiter's own cases live inlimiter-bounds.test.ts.RedisLimiternow records that itsresetAtisnow + windowMsrather than the key's remaining TTL, soRetry-Afterover-states the wait. Reading the real one needs apttlonRedisLike.Client, which is left as a deliberate change rather than made here.
Migration
| before | after |
|---|---|
const s = await auth.resolveSession(req) then if (!s) | const s = await auth.resolveSession(req).orNull() then if (!s) |
const u = await auth.identities.getById(id) then if (!u) | const u = await auth.identities.getById(id).orNull() then if (!u) |
const gone = await auth.sessions.revoke(sid) then if (!gone) | const gone = await auth.sessions.revoke(sid).orNull() then if (!gone) |
Appending .orNull() is the mechanical fix and preserves the old behaviour for absence
exactly, while a store outage now throws where it used to be indistinguishable from a miss.
.orDefault(x) and .wrap() are there when a fallback or a { data, error } pair reads
better. TypeScript finds most call sites, but not if (!x) guards or x?.y chains over a
value that can no longer be null — those compile and silently stop firing, so grep for them.
Also in this release
- The public-surface test now sweeps every method on every facet of the engine and the
transaction facade, failing the build by name if one answers
nullagain. Three synchronous lookups are documented exceptions:providers.resolveandtransport.extract(and the facade'sproviders.resolve). AUTH_SESSION_NOT_FOUNDandAUTH_SESSION_EXPIREDare declared but raised nowhere; they are left in place for this release rather than removed silently.
5.11.0
Minor Changes
- 7228f81: Storage adapters: one class per backend, implementing the engine's store
contracts directly, plus a
.wrap()escape hatch on every adapter call.
The bridge layer is gone
@gentleduck/auth/adapters/sql used to define a second, lower-level contract —
23 operations shaped around a where/patch pair — that each dialect
implemented and sqlStores then translated into the four stores the engine
actually binds. Two contracts for one job: every method was written twice, and
the translation layer was where the absent-row rule, the actor stamping and
the patchMetadata retry lived, invisible to the dialect that produced the row.
Each adapter now implements Identities.Store, Credential.Store,
Sessions.Store and Events.Store itself. What the translation layer held is
either inlined where it belongs or shared as a free helper on the new base.
Removed from @gentleduck/auth/adapters/sql: SqlBridge, SqlStore,
SqlStores, sqlStores, SqlIdentityStore, SqlCredentialStore,
SqlSessionStore, SqlEventStore, asBridge, orNull. The entry point keeps
withSqlEventLog and the JSON-column codecs, which were never part of it, and
is now @gentleduck/auth/adapters/drizzle: with the bridge gone there is no
dialect-agnostic SQL contract left for the old name to describe, and everything
behind it is shared only by the three drizzle dialects.
Renamed, with the two statics collapsed into one:
| before | after |
|---|---|
DrizzlePgBridge.bridge(db) / .storage(url) | new DrizzlePgAdapter(db | url | pool) |
DrizzleMysqlBridge.bridge(db) / .storage(url) | new DrizzleMysqlAdapter(db | url | pool) |
DrizzleSqliteBridge.bridge(db) / .storage(path) | new DrizzleSqliteAdapter(db | path | client) |
createSqlStores(bridge) | — (an adapter is already the stores) |
There is no open() static: the constructor takes the connection string, the
driver pool or a drizzle handle and resolves it, so new is the only way in.
MemoryIdentityStore, MemorySessionStore, MemoryCredentialStore and
MemoryOrgStore are no longer exported separately: MemoryAdapter is one class
on the same base, with the same four facets. memoryAdapter() and
MemoryAdapter#raw are unchanged.
One base class, one contract
@gentleduck/auth's adapter layer is now two declarations. Adapter is the
contract and nothing else: the four stores, the Answer<T> a call returns and
the Result<T> its wrap() hands back. AdapterStore is the class every
adapter extends: it takes its driver's error mapper and exposes run, the one
place a failure is typed and wrap is attached.
asAdapter is gone. It checked at runtime that an adapter had all four stores —
something implements Adapter.Me already proves at compile time — and used that
check to launder the profile type. DrizzlePgBridge.storage<MyProfile>(db) is now
new DrizzlePgAdapter(db): an adapter answers the profile shape its table
actually declares, so an app whose profile is narrower than that is told so by
the compiler rather than trusted at runtime.
answering(promise) is now AdapterStore.answer(promise). stamped and
rowWithLinks are gone: each had a one-line body behind a name, and both are now
written where they are used.
A store is plain CRUD
Identities.Store declared six optional set-based writes alongside the
single-row ones — softDeleteMany, restoreMany, eraseMany,
updateProfileMany, linkMany, unlinkMany — as a fast path a store could
implement instead of being looped over. No adapter ever did, in any dialect, so
every one of them was a branch that never ran and a compliance test that only
ever skipped. They are gone, and the contract is the single-row CRUD it always
was in practice.
AuthIdentities#softDeleteMany and its five siblings are unchanged: the facet
loops, reports one outcome per input row in input order, and names each refusal
the same way it did before.
Sessions keep theirs, and every store implements them
Sessions.Store kept three optional set-based forms —
deleteAllForIdentities, deleteMany and listByIdentities — and for a while
shared the identity side's problem: declared, consumed by the facet, implemented
by nobody, so revokeAllForIdentities over 500 people was about a thousand round
trips on Postgres where it should have been two statements. That is the one batch
path with a caller that matters — "sign this whole org out" — so the answer here
is the other one: every adapter implements all three.
pg and sqlite delete the set in one statement and read back the ids that matched;
mysql has no RETURNING, so the ids are selected under their own lock first, the
same read-then-delete _erase already does for credentials; memory makes one pass
over the map instead of one per identity; redis batches the record delete, the
expiry zrem and the per-identity srem into three round trips for the whole set
rather than three per identity.
outcomesFromAffected is shared from core/batch — the ids a statement affected
become one outcome per requested id, and an id that did not come back matched no
row, which is the only thing one statement can say about it.
RedisLike.Client gains an optional mget. listByIdentity backs the
active-devices screen and runs inside every revokeAllForIdentity, and it was
issuing one GET per session; it now reads them all in one round trip, falling
back to concurrent gets for a client without the command.
Credential.IStore.deleteByIdentities is gone. It had no implementation and,
unlike the session forms, no caller either — nothing in the package ever invoked
it, so it was a declaration and a compliance case and nothing else.
.wrap() — a failure as a value
Every adapter call now answers an Adapter.Answer<T>, a native promise with one
extra method:
const { data, error } = await storage.identities.find({ email }).wrap();
if (error) return reply(error.code);
error is the discriminant and is always an AuthError, never unknown: a
non-Error a driver throws becomes the cause of a real one rather than being
handed back raw. Answer<T> extends Promise<T>, so awaiting it throws exactly
as before and every existing caller — Promise.all included — is untouched.
Scope: wrap() catches what the call rejects with. An argument that throws
before there is a promise still throws at the call site; a bug in the call is not
a failure of the work.
error also carries the codes that call can raise, not a flat AuthError:
const { error } = await storage.sessions.update(id, patch).wrap();
// ^? AuthError<'AUTH_SESSION_REVOKED' | Adapter.Faults> | null
Adapter.Faults is what any call can raise — the whole range of a driver's
error mapper, since any statement can meet a constraint, a dead socket or a
missing schema. Adapter.Me names what each method adds on top: restore can
answer AUTH_GRACE_EXPIRED, patchMetadata and upsert
AUTH_CREDENTIAL_NOT_FOUND, update AUTH_SESSION_REVOKED. The union is
closed, so a switch on error.code is exhaustive, and comparing a read's
error against a code it cannot raise no longer compiles.
AdapterStore.answer(promise) now types the failure on the await path too, as
run always did, and attaches wrap to a promise of its own rather than to the
caller's.
Each adapter declares its facets as their slot in Adapter.Me rather than
checking an inferred object against the contract with satisfies. A satisfies
only proves the body is assignable, so the type a caller saw came from the body —
run's Adapter.Faults and nothing else — and the extra codes above were
declared where no call site could read them. storage.identities.restore(id)
now answers AuthError<'AUTH_GRACE_EXPIRED' | Adapter.Faults>, which is what
this section always said it did.
A missing row raises — .orNull() when absence is an answer
No adapter call returns null any more. A read or a write that matched no row
raises the code that names what was missing — AUTH_IDENTITY_NOT_FOUND,
AUTH_CREDENTIAL_NOT_FOUND, AUTH_SESSION_REVOKED, AUTH_ORG_NOT_FOUND,
AUTH_MEMBERSHIP_NOT_FOUND — instead of handing back a value the caller has to
remember to check.
The old contract typed some calls T | null and others T, so which ones could
miss was whatever each author had happened to think about. A caller that forgot
the check got undefined two frames later, with nothing naming the row that was
not there. Every store method is total now: Identities.Store,
Credential.Store, Sessions.Store and Org.Store all answer T.
Org.Store was the last holdout. getOrg, removeMember and setRoles each
answered null for a row that was not there, which is the exact shape the rest
of this section removed; AUTH_ORG_NOT_FOUND and AUTH_MEMBERSHIP_NOT_FOUND
are new so a membership miss cannot be reported as a missing org and send a
caller off to create one that already exists. orgs.get(), orgs.removeMember()
and orgs.setRoles() keep their nullable signatures — the conversion moved into
the facet, where the other facets already do it.
The mysql and sqlite adapter classes also carried a public find() that answered
null. It is the join pg keeps as private _find, used only from inside the
class, and it is now private in all three.
Absence is still an outcome for the callers it is an outcome for — "is this
address free", "is this login attached" — so Answer gains a third form
alongside await and .wrap():
const identity = await storage.identities.find({ email }).orNull();
if (!identity) return reply("free");
.orNull() converts exactly the five absence codes above and re-raises
everything else, so a dead socket or a constraint violation still throws rather
than reading as "not found". Where a facet's own public API is legitimately
nullable — auth.identities.getById, auth.sessions.getBySid — that is where
the conversion now happens, once, instead of at every call site behind it.
Removed: rotateOrThrow and patchMetadataOrThrow. They existed to turn a
null back into a throw, which is what the call does by itself now.
The memory adapter's ErrorMap also declares AUTH_IDENTITY_NOT_FOUND, which
it raises in nine places and never named. Undeclared, wrap() would have
re-labelled a plain 404 as AUTH_ADAPTER_FAILED.
Breaking. A hand-written Org.Store must raise where it returned null,
and declare both codes in its ErrorMap or wrap() re-labels them
AUTH_ADAPTER_FAILED. A caller that tested a store read for null now has a
branch that cannot be taken: narrowing Promise<T | null> to Promise<T> leaves an
existing if (!row) as dead code rather than a type error. Add .orNull() at
the call sites where the miss was the point, and delete the check everywhere
else — the raise carries the same fact with the row's name attached.
version moves on every write the row accepts
auth_identities.version and auth_credentials.version are what a conditional
update locks against, and only three writes were maintaining them:
identities.update, the credential rotate/patchMetadata pair and the family
revoke. softDelete, restore, link and unlink left the number where it
was on all four adapters, so a caller holding version N passed its
expectedVersion check on a row that had been hidden or re-linked underneath it
— the lost update the column exists to stop.
Every write that changes what a read answers now moves the version, including a
repeat link that inserts no second row: the version counts writes the row
accepted, not observable diffs, which keeps one rule across four adapters and
errs toward a spurious AUTH_STALE_WRITE rather than a missed one.
It costs nothing on Postgres and SQLite — the bump rides the .set() that was
already being sent, or replaces the trailing read via RETURNING/a writable
CTE, so link and unlink send the same number of statements they did before.
MySQL pays one statement on each, having neither.
runIdentityStoreCompliance pins it: every one of those writes must return a
higher version than the last, and the version read before them must then be
refused by update.
A batch answers the rows it touched
Every batch write now answers Me<Profile>[] — the same shape the single-row
form answers, once per row that landed:
const hidden = await auth.identities.softDeleteMany(ids);
const missed = ids.filter((id) => !hidden.some((row) => row.id === id));
It used to answer a Batch.Result<T> — { outcomes, applied, failed } — one
{ id, ok, reason } entry per input row, with reason drawn from its own
seven-value enum that a translation table mapped the thrown AuthError onto.
The whole structure is gone: Batch.Result, Batch.Outcome,
Batch.FailureReason, batchResult, toSoftReason, the BATCH_NOT_FOUND
sentinel, outcomesFromAffected, outcomesFromRows and the core/batch
module that held them.
Three things were wrong with it. The reason table was a second vocabulary for
refusals the engine already names, and it was a filter as well as a map: a code
it did not list was treated as a hard failure and killed the batch, so every new
per-row refusal silently became batch-fatal until somebody added a line. The
applied/failed counters were two derived numbers every producer had to
remember to compute, that nothing in the library read. And the wrapper meant a
batch was the one part of the API whose answer you could not use the way you use
every other answer.
What a caller gives up is the reason a particular row was refused — a closed grace window and a missing row now both read as an absent row. Diff the input against the answer to see which; call the single-row form on one to find out why.
The hard/soft split survives, because it is not about reporting: a refusal is
soft when it is an AuthError with no cause, and hard when it carries one. A
soft refusal leaves its row out of the answer; a hard one throws and takes the
batch with it, because a driver error leaves a Postgres transaction aborted and
swallowing one would make the caller's COMMIT a silent ROLLBACK after it had
been handed a list of rows that "landed". That rule is one seven-line function
in core/identities, applied by the four batch writes that loop.
A store method answers the rows it wrote
The set-based store methods are no longer optional, and they answer rows rather
than ids. Identities.Store.softDeleteMany and eraseMany answer
Me<Profile>[], so a caller reads the new deletedAt, the bumped version and
the providers array off the write instead of asking again; eraseMany answers
each row as it stood immediately before it went, which is the last moment anyone
can see it. Sessions.Store.deleteAllForIdentities and deleteMany answer the
{ id, identityId } of every session they removed.
These five were declared ?: and every shipped adapter — memory, all three
dialects, redis — implemented all of them anyway, so each facet carried a
one-statement fast path and a loop that nothing ever took. The per-row
bookkeeping that used to be repeated in nineteen adapter call sites is gone
outright rather than moved: the facet returns what the store answered. An
adapter no longer imports core/batch, because there is no core/batch.
Postgres and SQLite widen a RETURNING clause they were already sending —
Postgres joins the links back through the same writable CTE its single-row
erase uses, SQLite reads them alongside as its erase does. MySQL, which has
neither, widens the locked SELECT it already takes before the delete.
Answering the removed sessions also retires two reads. The facet emits one
session.revoked per row, so it had been reading the rows before the delete
to learn their names: revokeByHashes ran an N-call getByHash map and
revokeAllForIdentities a whole listByIdentities. Both are gone — the delete
already knew. Redis names the rows off the identity index rather than the
records, so a record that no longer parses still counts as revoked.
Sessions.Store.listByIdentities is removed. Its only caller was that pre-read,
and leaving a required method nothing calls is a method every custom adapter
implements for nothing.
runIdentityStoreCompliance and runSessionStoreCompliance dropped the five
ctx.skip() guards that went with the optionality — they could no longer fire,
and a suite that reports a skip nobody can trigger reads as coverage.
Breaking. A batch method answers T[] where it answered Batch.Result<T>:
what was result.outcomes.filter((o) => o.ok).map((o) => o.value) is the answer
itself, result.applied is answer.length, and a refused row is absent rather
than present with ok: false. Batch is no longer exported. A custom adapter
must implement softDeleteMany, eraseMany, deleteAllForIdentities and
deleteMany, answering rows, and may drop listByIdentities.
identities.find takes one address, not a list of spellings
Identities.Store.find is one union of three shapes, and the email arm is a
string:
find(by: { id: string } | { email: string } | { providerId: string; providerSub: string }): Promise<Me<Profile>>
It used to take string | readonly string[] there, because a row written before
addresses were normalised holds the bytes its client sent — so a lookup for the
canonical spelling alone would lock its owner out. Six call sites each expanded
the address themselves and passed the list down, with their own ?? fallback
for a blank one. The expansion now happens once, inside the store, where the
comparison is: toEmailList takes one address and answers every spelling to try.
That also retires an invariant that had to be checked at runtime. An empty list
is no condition at all, so find({ email: [] }) would have matched the first
live row; toEmailList used to throw AUTH_INVALID_PARAMETERS to stop it. One
address always expands to at least one spelling, so the case cannot arise, and a
blank address answers [''], which matches nothing because every dialect
refuses to store a blank one.
Every mutating write on the store answers Me<Profile> for the same reason
find does: softDelete, restore, erase, link and unlink hand back the
row they touched rather than void, so a caller needing the new
deletedAt or the providers array after a link does not read again to find out.
Breaking. find({ email: [...] }) takes the address itself. Identities.By
is gone — the union is written out where it is used. toEmailList takes a
string and no longer throws.
Fixes this turned up
linkagainst an identity that no longer exists surfaced the raw foreign-key violation; it now raisesAUTH_IDENTITY_NOT_FOUND, as every other call that matches no row does.- A soft delete no longer takes
deletedAt/deletedByfrom the caller: the window is a grace period in milliseconds and the actor is the ambient one, so two dialects can no longer disagree about when a row becomes purgeable. - An empty session patch is a no-op rather than a driver syntax error, and a
missing session answers
AUTH_SESSION_REVOKEDon every backend. patchMetadataon a row that is not there (or not this tenant's) answersAUTH_CREDENTIAL_NOT_FOUNDrather than telling the caller to retry.
One read per adapter, and no handle to pass
Each dialect had a private read taking the client to read through, so all ten of
its call sites passed this._db to say "the usual one". A read no caller runs
inside a transaction now just uses the adapter's own handle — the parameter is
gone from Postgres's _find and SQLite's _linksFor. The rest take it as an
argument defaulting to this._db, so a transaction passes its tx and no call
site can quietly read outside the transaction it is in.
MySQL reached those through this.withClient(tx), which re-runs the adapter
constructor on every transactional read: a createRequire built and discarded
on Postgres, a pragma foreign_keys that SQLite ignores inside a transaction
anyway. It threads the handle like the other two now, so one mechanism answers
this rather than two, and withClient is left as what its contract says it is —
how a caller rebinds the adapter onto a transaction of their own.
The handle aliases went with it. Pg.Handle, Mysql.AnyMySql2Database and
Sqlite.AnySqliteDatabase each named the type a statement runs on, and each had
one or two uses left, all inside its own adapter. They spell the drizzle type
directly now — SQLite's onto the Db<> already declared beside it — so no
dialect exports a handle-shaped type any more.
Types that named nothing
Pg.AnyNodePgDatabase had no uses at all. OidcOP.Prompt listed the four
values OIDC defines for prompt and nothing read it, because the OP does not
implement the parameter — select_account appears nowhere else in the package.
Removing the type does not remove a feature; it stops advertising one that was
never there.
Mfa.TotpMetadata and Mfa.PasskeyMetadata described the metadata shapes the
MFA facet reads. It reads them through the credential-utils predicates now —
isProfileBooleanTrue(row.metadata, 'confirmed'), getProfileNumber(row.metadata, 'lastTotpStep') — with the field names pinned in the credential allow-lists,
which is the shape that actually holds at runtime. The passkey one also repeated
two fields Passkey.CredentialMetadata already declares.
Mfa.WebauthnChallengeStore was Passkey.ChallengeStore field for field, in a
file that already imports Passkey and whose doc comment already named the
passkey provider's store as the reference implementation. It is that type now.
The three email channels each declared their own ITemplateResolver, identical
across all three. One Channel.IEmailTemplateResolver replaces them. Twilio and
web-push keep their own, which answer { body } and { payload, ttl? } rather
than a subject and an HTML body.
MySQL had three row helpers where the others had one: a locked re-read, a links read and an assembler over both. It now answers every lookup through the same single join the other two dialects use, leaving one helper — the lock a write takes before it re-reads.
On Postgres, create and erase are one statement each rather than a
transaction around two. The row, its logins and the read that answers them
travel as one CTE, with the links taking the id straight out of it; erase
reads the logins in the same with that deletes their owner, since every arm
of one sees the snapshot the statement opened on. Four round trips become one,
and the row an erase answers with now carries the logins the cascade took —
proven for every adapter, not just this one.
One withClient, on the adapter
A transaction was joined store by store: every store contract carried its own
optional withClient, each dialect implemented four one-line delegates back to
the adapter's own, and withTransaction called them one at a time. The facets
come off one adapter and share its connection, so that was four answers to one
question.
withClient belongs to the adapter now — Adapter.Me declares it, the three
drizzle adapters already had it, and withTransaction makes the one call. The
capability hook follows: MfaFacet and ApiKeysFacet are handed the bound
facets instead of a driver handle to rebind a store of their own from, so
Capability.withClient(stores, events) replaces (client, events) and neither
can answer null any more.
Hand the engine the adapter — stores: adapter — and transactions work. A bag
assembled facet by facet (redis sessions beside a drizzle identities store, say)
has no withClient, and withTransaction throws AUTH_MISCONFIGURED saying so
rather than leaving a write outside the caller's transaction. createTest takes
the same shape: one stores override in place of four per-facet ones.
Fewer statements per call
What a store call costs on a real database is mostly the number of statements it sends: each one is a round trip the caller waits through. Four shapes sent more than they had to, on every dialect that had them.
link read the identity to check it was there, inserted the login, then read the
identity again to answer. The first read is gone — the insert takes its row from
auth_identities, so an identity that is gone or hidden writes nothing and the
read that answers raises on its own. MySQL also held a locked read and a
second read for "does this identity already hold this provider", both inside a
transaction; the repeat is settled in the same where now. Three statements to
two on Postgres and SQLite, six to two on MySQL, and linking to a soft-deleted
identity no longer writes a row on MySQL that the answer then hides.
patchMetadata read the row, merged in JS, wrote against the version it had read
and retried once when a concurrent write took that version first. It is one
update now — metadata || $patch::jsonb on Postgres, json_set on SQLite and
MySQL — so the merge happens under the row's own lock and there is no window to
lose. Not json_patch/json_merge_patch: those are RFC 7396, where a null value
removes the key rather than storing it, which is not what the object spread the
memory adapter runs does.
MySQL took a select … for update before update, softDelete, rotate,
revoke and patchMetadata to learn whether the row was there at the version
expected. The write takes the same lock and affectedRows answers the same
question, so the probe is gone from all five, and restore no longer reads the
row twice. erase keeps its locked read: it has to answer with the row it
removed, so that read comes before the delete either way.
The memory adapter's create checked the address, the handle and each login in a
pass of its own; it is one scan now, which is also the one place the three unique
indexes every dialect carries are written down together.
One fix fell out of it: patchMetadata could raise AUTH_STALE_WRITE after
losing the version twice, a code its own fault union does not name and a retry
its caller could not act on. There is no version to lose now, so the only failure
left is the AUTH_CREDENTIAL_NOT_FOUND the contract declares.
bun run bench:adapters times all 26 store calls on all four adapters. The
before and after tables, per call, are in src/adapters/README.md.
An identity store answers one read. findById, findByEmail and
findByProviderSub are find(by), named with { id }, { email } or
{ providerId, providerSub } — three names for one join, one live filter and
one order, where only the condition ever differed. auth.identities.getById
and its siblings are unchanged; it is stores that change shape, so
stores.identities.findById(id) is now stores.identities.find({ id }).
The where clauses that scope a read to a tenant were each a mutable array and
a pair of ifs; they are one and(...) with a shared inTenant filter that
drops out when the caller named no tenant.
MySQL read an identity by address 50x slower than by any other key. The
where matched lower(profile ->> '$.email') while the unique index lives on
the email_norm column generated from that expression, and MySQL will not use
a generated-column index when the comparison carries the connection's
collation - so every lookup scanned the table. It matches the stored column
now: 24 ms to 0.28 ms over 50k rows. No test could see it, since every
assertion passed either way on a table of ten rows.
bun run bench:adapters is what found it: the same 50k identities and 100k
logins on all four adapters, timed at the adapter boundary. The numbers, the
plans behind them and the method are in src/adapters/README.md.
One way a driver failure is named
Each dialect had its own translator: the same five makers, the same socket-code
list and its own four-deep cause-chain walker, restated three times, with a
Make that took one argument in two of them and two in the third.
There is now one reader — signalOf — and one resolver, and each dialect is
only its own table of what its driver says: the text it names the refused index
in, the code or errno it gives, the family that code belongs to, read in that
order. 256 lines became 189, and nothing an adapter answers changed.
AuthError itself no longer declares each code's HTTP status twice — once as a
literal on the union member and once in the status table that is the only one
ever read — and the class is free of type assertions: the origin argument is
narrowed by a predicate, and toJSON scrubs through a function that returns a
record rather than one that is cast to it.
The three dialects now refuse the same row
A schema audit across auth_identities, auth_identity_providers,
auth_credentials and auth_sessions found the declarations in parity — 37
identically named indexes and checks, 18 of them checks, matched by the
generated e2e DDL — while what those declarations meant diverged per dialect.
MySQL compared opaque keys case- and accent-insensitively. Nothing set a
collation, so id, identity_id, tenant_id, secret, csrf_hash,
provider_id and provider_sub inherited the server default —
utf8mb4_0900_ai_ci on MySQL 8. pg and sqlite compare all seven byte-for-byte,
so the same lookup answered differently per backend: findByHashedSecret
matched a secret whose case it was never given, two base64url passkey
credential ids differing only in case collided, and
uq_auth_identity_providers_sub refused a sub that merely case-differed from
one already stored. They are now ascii_bin, or utf8mb4_bin where the value
is the app's to choose rather than this library's.
A passkey could register on two dialects and fail on the third. secret
was sized 512 for Argon2id PHC strings, but the passkey provider writes
credential.id verbatim and WebAuthn allows that up to 1023 bytes — about 1364
base64url characters. pg and sqlite type the column text. It is now 1400
ASCII characters, which keeps auth_credentials_kind_secret inside InnoDB's
3072-byte key limit.
A second password is a write error rather than a second way in.
setPassword deletes the previous row before writing the next, so one row per
identity was an invariant held by hand and by nothing else. It is now a unique
index — partial on pg and sqlite, a generated-column carrier on MySQL, which
has no partial index — and tenant-scoped, because that delete is tenant-scoped
and two tenants may each hold one.
Retention has an index and a profile has one width. deleted_at was
unindexed on all three, so sweeping soft-deleted identities scanned the table;
it is now indexed, partially where the dialect allows. email and username
are capped at 320 and 191 on all three, the widths MySQL's generated norm
columns already imposed and the other two silently did not. And
chk_auth_sessions_id_length now reads char_length on MySQL, where length
counts bytes — the one check of the three that asked a different question.
Deliberately unchanged: (kind, secret) stays non-unique, because
findByHashedSecret is specified to answer the freshest live row and fall back
to a revoked one, which needs both to exist at once; and pg keeps uuid where
MySQL and sqlite take a string, which ComplianceIds already exists to absorb.
A credential row has a shape that is safe to hand out
Credential.Me carries secret, and every store read returns it —
findById, listByIdentity, upsert, revoke, delete and the rest. The
facets that ship today each hand-build a DTO, so nothing leaks, but that is a
convention held at every call site rather than anything the type enforces, and
the field is worse than its own docblock claimed: it read "never plaintext"
while kind: 'totp' stores the base32 seed exactly that way, so one
res.json(row) from a new facet or a plugin is a factor a caller can replay.
Credential.Public (Omit<Me, 'secret'>) and toPublicCredential give that
convention a name, and the docblock now says what the column actually holds.
Identities.ExportBlob already hand-wrote Omit<Credential.Me, 'secret'> and
exportAll already destructured the secret off row by row; both now say
Credential.Public and toPublicCredential instead.
core/credentials only holds credential helpers now
Six of its fourteen exports read no credential. getProfileString,
getProfileNumber, isProfileBooleanTrue, isProfileBooleanFalse,
isFiniteNumber and isExpiredAt take an unknown, which is why the
transport, idempotency and sessions modules were all importing a credentials
module to check a JWT key's notAfter or an idempotency entry's expiry. They
move to core/predicates. isSoftDeleted reads an identity row, and moves to
core/identities beside the type it tests.
deleteCredentialsByPurpose is gone. The store contract grew
deleteByKindAndPurpose, which does the same job in one round trip, but the
helper was never retired and usage went the wrong way: five call sites on the
helper's list-then-delete-each loop against one on the store method. All six now
call the store, so regenerating backup codes is one statement rather than a list
plus a delete per code.
None of these were exported from the package, so nothing downstream moves.
The purposes table is now the only spelling of a purpose
RECOVERY_PURPOSES existed but half the codebase went around it: eleven bare
'signup-flow' / 'password-reset' / 'email-verification' /
'trusted-device' literals sat in the signup, reset, verification and
remember-me paths, and backup-codes.ts kept a BACKUP_CODE_PURPOSE alias that
pointed at nothing else. A purpose is the discriminator that decides which of
seven token families a delete touches, so a typo in one of those literals writes
a row no read path can find. All eleven now read the constant and the alias is
inlined.
Both constants move to credentials.constants.ts, matching identities and
sessions. core also exported AUTH_CREDENTIAL_KINDS under export type,
which hid the runtime array from anyone importing it; it is a value export now,
and RECOVERY_PURPOSES is exported beside it.
oauth access tokens are no longer stored
kind: 'oauth' hashed the refresh token into secret and then wrote the access
token into metadata.accessToken in the clear. metadata is a
Record<string, unknown>, so Omit<Me, 'secret'> could not see it, and
exportAll reads every kind — meaning the GDPR Article 20 archive a user
downloads carried live third-party bearer tokens.
Nothing needed the stored copy. authRefreshoauthToken already returns the
fresh token to its caller in hand, and the only reader was
projectAccessToken, which had no production callers. So signin and both
rotation branches stop writing it, parseFamilyMetadata drops the field rather
than carrying it forward (a rotation purges a row written by an older version),
and projectAccessToken is gone.
Because a row that never rotates would otherwise keep leaking on export,
toPublicCredential also redacts SECRET_METADATA_KEYS from metadata. The
regression test asserts on the serialised export, which is where this surfaced
rather than anywhere the type system could reach.
The e2e tier was never actually running
Three separate faults kept 31 files and 693 tests from proving anything, each of which hid the next.
The CI e2e job ran --filter=@gentleduck/iam only, so @gentleduck/auth's
suites — real Postgres, MySQL and Valkey, the three backends with no in-process
substitute — ran in no job on any merge. That is the exact gap the job was
added to close, closed for one package and not the other. It now filters both,
pre-pulls mysql:8.4, and sets DUCKAUTH_E2E_REQUIRE=1.
Locally the tier skipped itself and exited 0. That was deliberate for a machine
without docker, but it also swallowed the case where docker answers and the
backends then fail to come up — a prerequisite that is present and broken, not
absent. setup() now only skips when docker is unavailable; a provisioning
failure throws.
What that was hiding: the MySQL schema no longer applied. password_key was a
STORED generated column reading identity_id, and MySQL refuses a cascading
foreign key on a column a stored column depends on (error 1215), so every run
died at the ALTER and skipped all 693 tests behind a green exit code. The
column is VIRTUAL now, which MySQL accepts, still indexes for the unique
constraint, and still cascades.
With the tier running, two Postgres failures surfaced. The credential ordering
test built three password rows for one identity, a state
uq_auth_credentials_password forbids and PasswordsProvider.set cannot
produce — it deletes before it upserts. It now uses api-key, one of the kinds
that may legitimately have several, which is the only case that ordering ever
decides. And the constraint-coverage test named four rules the schema declares
that nothing exercised: both password unique indexes and the two identity
length checks. Each has a violation case now, so each is proven to answer a
typed error rather than a raw driver fault.
findByProviderSub is gone from the credential store
Credential.Store.findByProviderSub was declared on the public contract and
implemented by all four adapters, and nothing called it. The identities store
has its own provider-sub lookup, which is what the oauth provider actually uses;
this was the credential-side twin, left behind.
It was not free. Postgres and SQLite each carried a partial expression index
over metadata->>'provider' and metadata->>'sub' to serve it, and MySQL —
which cannot index a JSON path — carried two STORED generated columns,
oauth_provider and oauth_sub, plus an index over them, plus the select()
exclusion those columns forced on every credential read. All of it existed for a
method with no callers. The method, the three indexes, the two generated columns
and the exclusion are removed together.
A read that answered in the wrong order
Sweeping the adapters one at a time turned up three more places where a store answered differently from its peers, all of them in a read's order rather than its contents, which is why every existing case passed.
credentials.listByIdentity is documented newest-first, created_at descending
with the id breaking a same-millisecond tie, and callers lean on it: passwords
rotates "the first live row" and mfa.confirm takes the first unconfirmed
enrollment. The memory store did no sorting at all and answered in insertion
order — exactly reversed. A user who abandons one TOTP QR and scans a second one
has two unconfirmed rows, and memory confirmed against the one they walked away
from. It sorts now, and mints authUuidV7 ids like the dialects do, so the
tie-break orders by mint time rather than by a random token.
updatedAt moved on link and unlink in every dialect, through the column's
own $onUpdate, and stood still in memory. An identity whose logins had been
rewired still reported as untouched since signup.
On SQLite both timestamp defaults were unixepoch() * 1000 — whole seconds
widened to milliseconds, not milliseconds. findByHashedSecret orders on
created_at and promises the freshest live row, so re-issuing a key inside the
same second made the choice between the old row and the new one a coin toss. The
default is unixepoch('subsec') now, which needs SQLite 3.42. The generated DDL
is regenerated to match.
All three are covered in the shared compliance matrix rather than in one adapter's own tests, so every store answers to them.
Nothing was found in the Redis session store or in the MySQL adapter: both were
read end to end. Left as observations rather than changes — SQLite's erase
reads the links and deletes in two statements with no transaction around them,
its pragma foreign_keys is not awaited on an async driver, and MySQL's create
answers with providers in added_at order where the others answer in the order
they were given.
Three places the stores disagreed with each other
An audit of the four adapters against one another turned up behaviour the compliance matrix never asked about, so each store was free to answer differently. The matrix now covers all three, and every store answers the same.
sessions.create on an id that is already stored was an overwrite in memory and
a unique violation everywhere else. The id is the caller's token hash, so a
repeat is a collision: taking it as an update hands whoever still holds the first
token a session it never opened, at whatever AAL the second call asked for. The
memory store refuses it now. Redis and Valkey were already refusing it — SET … NX is there for exactly this — but reported it as AUTH_SESSION_REVOKED,
which tells a caller its session is gone rather than that the id is taken; they
raise AUTH_ALREADY_EXISTS, the code every dialect's unique violation maps to.
identities.restore on a row that was never hidden answered with the row on all
three dialects and raised AUTH_GRACE_EXPIRED in memory, which tells a caller
retrying a restore that the account is past saving when it is simply already
live. The memory store's test was !deletedAtMs, so an epoch deletedAt read as
live too. It now hands back the live row, as the dialects do.
credentials.revokeFamily stamped updatedBy on the dialects and left it alone
in memory. A family revocation is the breach response, so "who pulled the
trigger" is the audit question it exists to answer; memory stamps it now.
Still open, and deliberately not changed here: revoke on an already-revoked
credential is a no-op in memory but rewrites revokedAt and bumps the version on
all three dialects, because _write has no isNull(revokedAt) guard where
revokeFamily does. The dialects are the ones moving the revocation timestamp,
so the fix belongs on that side, not in memory. Relatedly, rotate,
patchMetadata and revoke leave updatedBy naming whoever created the row
rather than whoever performed the write — uniform across all four stores, so it
is a contract question rather than a divergence.
credentials.upsert is credentials.create
It never upserted. All four adapters issued a plain insert with no conflict
clause, and CreateInput carries no id or unique key to match an existing row
on, so every call appended a credential. The name promised the one behaviour
callers would reach for it to get: passwords.setPassword works around its
absence by deleting the previous row first, in a comment that says the contract
has no single-call replace.
Credential.Store.upsert is now create, matching Identity.Store.create and
Session.Store.create; Credential.UpsertInput is CreateInput, matching
Identity.CreateInput and Session.CreateInput; and the toCredentialUpsert
input builder is toCredentialCreate. Behaviour is unchanged — both tiers pass
at their existing counts.
The consent store's upsert in @gentleduck/auth/oidc keeps its name: it issues
ON CONFLICT/ON DUPLICATE KEY and genuinely is one.
Credential metadata exports by allow-list, not deny-list
toPublicCredential used to strip a named list of secret-bearing metadata keys.
That fails open: a key that turns out to hold a secret leaks from the GDPR export
until someone remembers to name it, which is exactly how the oauth access token
got out in the first place.
PUBLIC_METADATA_KEYS now declares, per kind, the metadata keys that may leave
the engine, and everything else is dropped. Adding a kind is a compile error
until it has an entry, and adding a key that is not listed simply does not
export. The trade is deliberate: operator-written metadata — the opts.metadata
merged into a trusted-device row, and the operator fields parseFamilyMetadata
preserves on an oauth row — no longer appears in the export, because the engine
cannot vouch for what an operator put there.
Verified by mutation: restoring the pass-through fails four tests, including the export regression that asserts on the serialised archive.
Doc comments say what the code does
A sweep over every module in the package: core, providers, adapters,
server, client, channels, cli, limiters, oidc, openapi, i18n,
telemetry and test. Comments that restated the signature they sat above are
gone, multi-line blocks are compressed to the clause that is not derivable from
the code, and SECURITY / WARN / NOTE invariants that were buried
mid-paragraph now open their own line so they survive being skimmed.
Several comments named things that do not exist, which is the part a reader cannot catch on their own:
SessionsFacet,AuthMemoryAdapter,AuthNoopLimiterandLimiter.IResultwere all referenced by docs; the real names areSessionsImpl,MemoryAdapter,NoopLimiterandLimiter.Result.- Route and import examples were written
/AUTH/signinand@gentleduck/AUTH/client/react. The client's own default base URL is/authand the package is@gentleduck/auth, so an example copied out of a doc comment did not resolve. Eighteen such lines are corrected. - The delete-account confirmation path documented
/AUTH/delete-accountagainst a real default of/auth/delete-account. - Comments named error codes
AUTH/MISCONFIGURED,AUTH/MAINTENANCE,AUTH/READONLY_MODE,AUTH/PROVIDER_FAILEDandAUTH/oauth/REUSE_DETECTED. Every code inerrors.codes.tsis spelledAUTH_*, so none of those matched anything a reader could grep for.
Every {@link} in the package now resolves: a target's first segment is
lexically visible in the file it is written in, same-file members are qualified
with their owning namespace, and a target that was out of scope is a plain code
span rather than an import added only to satisfy a doc.
A hidden identity takes no write
find refused a soft-deleted row from the start, but the write paths did not,
and a caller reaching one only needs an id and a version it read before the
delete landed. link, unlink and update now gate on isNull(deletedAt) on
every dialect, the memory store included, which had been the loosest of the
four: its link and unlink read straight out of the map and never looked at
deletedAt at all.
update is the one that mattered most. softDelete clears emailVerified
precisely so that a restore cannot hand back a claim nobody re-proved, and an
update landing on a hidden row put it straight back.
link and unlink are one statement or none
Both wrote the link table first and only then ran the gated version bump, on
separate connections. A softDelete arriving between the two left the write
half-applied while the caller was told it had failed — for link, the provider
sub stayed claimed by a hidden row no other identity could take it from; for
unlink, the login was detached and the caller told it was not. Both now run
in one transaction on pg, mysql and sqlite.
A soft delete becomes a delete
Identities.Store gains gc(now), alongside the gc(now) sessions already
had. Nothing purged an identity whose grace window had closed, so a deleted
account was hidden forever rather than deleted: unreachable, unrestorable, and
still holding its email, username and provider subs. The caller schedules it,
under a leader lock in a distributed deployment, and reaches it as
identities.gc().
This adds a method to Identities.Store, so a custom adapter must implement it.
A whole-store cast, removed
forProfile took an identities store and returned it as a store of a narrower
Profile — a function whose entire body was as, wrapping every method mysql
and sqlite defined. Its own comment admitted the problem: it narrowed a whole
store without checking anything. Both dialects now declare the store the way pg
always did, and the narrowing happens where a row is built rather than over an
entire API.
The narrowing itself cannot go away: a drizzle table is a module singleton whose
$type is the base profile, so a row read back is always the supertype of the
Profile the adapter was given. It is now written at each of the eleven points a
row is constructed instead of behind one helper; three of those build an empty
link list, which TypeScript infers as never[] and so needs its element type
named as well.
lockRow, _eraseIdentities and _hideIdentities had one caller each and are
now written at the call site, so eraseMany and softDeleteMany read the way
erase and softDelete next to them already did.
identities.merge is gone
It re-pointed a duplicate's credentials, sessions and provider links onto a survivor and erased the duplicate. Nothing in the engine ever called it: no flow reached it, and it existed for an admin or import job to call by hand.
It only ever moved the auth tables. An app deduplicating two accounts has to
move its own rows — orders, posts, whatever is keyed by identity id — so it is
writing that transaction anyway, and withClient(tx) lets it do the auth half
inside the same one. A method every adapter had to implement, that erases a row,
that the library never called, and that did half the job is not worth its
contract slot.
link stays and is unaffected: it is the before case, a signed-in account
adding a provider. What goes is the after case.
Breaking. Identities.Store.merge, identities.merge() and the
identity.merged event are removed.
The unit tier is 3321 tests and the e2e tier 739: three new contract cases pin
the soft-delete gate, the two atomicity fixes and gc across all four stores
— memory and sqlite in the unit tier, pg and mysql in e2e — three more pin the
orgs store raising and surviving wrap(), three cover the sqlite adapter on an
async driver, two guard the pg and mysql fixtures against losing their second
connection, and the merge cases are gone with the method.
The sqlite adapter had never run on an async driver
_atomic branches on the driver. A sync one is bracketed by hand on its single
connection; an async one goes through the driver's own transaction. Every
sqlite suite built a sync driver — bun:sqlite under Bun, better-sqlite3 or
node:sqlite under Node — so the async branch ran nowhere, and libsql and Turso
go down it.
It was worse than an untested branch. A sync driver's _atomic calls
run(this._db), so the tx a write is handed is the adapter's own handle,
and a private read threaded with that handle cannot be told apart from one that
ignores it. Dropping the handle from _links and reading on this._db passed
all 3262 tests. The equivalent edit on a dialect whose transaction really is a
separate connection fails loudly — 5 e2e tests for Postgres's _linkedTo, 47
for MySQL's _find — which is how a real transaction behaves.
async-driver.test.ts builds the shape a remote driver has, where the
transaction holds its own connection: two connections over one WAL file, the
handle answering async so _atomic takes the driver's transaction, and that
transaction handed a handle on the second connection. A read on the adapter's
own handle then sees the snapshot from before the transaction opened. link and
unlink are pinned there, and the same dropped-handle edit now fails both —
unlink returning the two logins the row had before the write rather than the
one it has after. A third case asserts the fixture's two handles really are
separate connections, so the other two cannot go quietly toothless.
Postgres and MySQL never had the branch, being async throughout, and their
fixtures were already multi-connection: new Pool(...) for one, and for the
other new DrizzleMysqlAdapter(url) building its handle with createPool. But
nothing said so, and a max: 1 or connectionLimit: 1 would have cost both
suites the ability to tell a read that used its transaction handle from one that
ignored it, while still passing. Each now carries the same guard: inside a
transaction it compares what the transaction sees against what the adapter's own
handle sees, and fails if they agree. Set either pool to one connection and the
guard reports it in about five seconds, naming the cause, rather than blocking
until the suite times out.
There was no live instance of the bug: no adapter reads this._db inside a
transaction callback.
A write count only one MySQL driver answers
The OIDC OP stores gate single use on how many rows a write touched:
codes.consume deletes and refreshTokens.consume stamps consumed_at, and
each returns the row only when exactly one row moved. That is the whole of
one-time codes and refresh rotation on MySQL, which has no RETURNING to settle
it the way pg and sqlite do.
The count was read by a helper that understood one shape: [ResultSetHeader],
which is what mysql2 answers. The same file's garbage collector carried a second,
private copy of the helper that also understood { rowsAffected } — what the
serverless MySQL drivers answer — so the two helpers disagreed about what a write
had done, in one file, and the factory's parameter type admits both drivers. On
one of the drivers the store helper could not read, every consume would find
nothing written and return null: the row is deleted or marked, and the caller is
told the code was no good. An authorization code that is spent and refused at the
same time, and a refresh token that rotates into a dead end.
The two are now one helper, the GC's more capable one, reading either shape and
narrowing at runtime rather than asserting a type. mysql-driver-shape.test.ts
pins it by handing codes.consume a stub database that answers in each driver's
shape; put the old helper back and the serverless case fails on its own.
The rule each dialect keeps differently was the one nothing tested
An identity may hold one password globally and one per tenant. Postgres and
sqlite each spend two partial indexes on it. MySQL can do neither, so it keeps a
generated password_key — case when kind = 'password' then concat(identity_id, ':', coalesce(tenant_id, '')) end — and one unique index over it, the coalesce
being what stops a global row keying NULL and slipping past an index that lets
NULLs repeat.
pg tested both of its indexes and sqlite tested both of its scopes. MySQL, the only one whose enforcement is a hand-rolled expression, tested none of it. It now answers the same four questions: a second global password is refused, a second within one tenant is refused, the global scope and two tenants stay apart, and a non-password kind is not constrained at all.
The OIDC garbage collectors had the same shape of gap. All three return how many
rows they pruned, sqlite's was the only one a test had ever called, and the
counts are exactly where the dialects differ — pg and sqlite count RETURNING
rows, MySQL reads the driver's envelope. pg and MySQL now run the prune case too,
all three off one fixture in the shared compliance module rather than sqlite's
private copy of it.
The contract was not reachable from outside the package
Adapter.Me is what an adapter must be, and AdapterStore is what makes one:
Adapter.Wrapped<S> maps every store method to an Answer, which carries
wrap() and orNull(), and AdapterStore.answer is the only thing that builds
one — including the re-labelling that keeps a code the mapper never declared from
being passed off as one it did. Both lived in src/adapters/adapter.ts, and no
entry in the exports map led there.
The effect was visible in the shipped types: dist/adapters/drizzle/pg/index.d.ts
says DrizzlePgAdapter ... extends AdapterStore<SqlFault> implements Adapter.Me<Profile>, with both names imported from an internal chunk that
consumers have no path to. A store written outside this package could therefore
not be declared as an adapter, and could not answer like one without rebuilding
Answer by hand, the security-relevant part included. This is the same failure
the root barrel already had and fixed — you could call a method but not write
down the type of what came back — except here it is what the engine takes
rather than what it returns.
There is now an @gentleduck/auth/adapters entry exporting exactly those two
names, beside the per-dialect entries that were already there. Nothing moved and
nothing else was added to it: the dialect adapters keep their own entries, so the
new one pulls in no driver. adapter.test.ts pins both halves — the type aliases
stop compiling if Adapter leaves the entry, and the runtime key check fails if
AdapterStore does.
Three tenant-scoped writes that no test held to it
Every method on the credential store takes a TenantContext, and the compliance
matrix already said why that matters: a context taken and ignored is worse than
one that is absent, because the caller believes it is scoped. It proved the point
for findById, findByHashedSecret, listByIdentity, rotate, revoke,
delete and revokeFamily.
It did not prove it for patchMetadata, deleteByKind or
deleteByKindAndPurpose. All four adapters do scope them — the SQL dialects
through _write and inTenant, memory through _inTenant — but nothing held
them there. Dropping the tenant from all three on the sqlite adapter left the
unit tier green at 3283 and tsc clean: a cross-tenant metadata rewrite, which
on an api-key row means its scope, and two cross-tenant bulk deletes, all
shipping unnoticed.
The three cases are in the shared matrix, so they run on every store rather than the one that happened to be audited. Each is written so it cannot pass by doing nothing: the sweeps assert the id they removed, which a sweep reaching across tenants and a sweep reaching nothing both fail. The same mutation now fails exactly those three.
Identity rows take no tenant context at all, and both session methods that do were already covered, so this closes the set.
The widest delete in the module had no case for an empty list
Four store methods take a list of ids rather than one: softDeleteMany and
eraseMany on identities, deleteAllForIdentities and deleteMany on
sessions. On the SQL dialects each reaches its table through inArray(col, ids)
and nothing else — no tenant, no owner, no second predicate narrowing what the
statement can touch. The list is the where clause.
drizzle renders inArray(col, []) as false, so an empty list matches no rows.
That is the right answer and it is the one shipping. But it is a detail of a
third-party implementation that the two widest deletes in the module depend on
entirely, and nothing here observed it. The spelling a reader adding a guard
would reach for first, ids.length === 0 ? undefined : inArray(col, ids), is a
where with no condition at all — which is every row in the table. And a caller
holding no ids is ordinary: a sweep with nothing to sweep. The gap between
no-op and wipe is one refactor wide.
No guard was added. drizzle is already correct, and repeating the check at each call site would only duplicate it less reliably. What was missing was a test, and there was none: no case in the matrix had ever passed an empty list to any of the four.
Two now do, one per store, and each asserts both halves — the bulk call answers
with an empty list, and a row created beforehand is still readable afterwards.
The undefined spelling above, applied to sqlite's identity erase and session
delete, fails exactly those two cases and nothing else.
With these, every method on all three store contracts — 11 on identities, 11 on credentials, 9 on sessions — has at least one case in the shared matrix.
Three of the four adapters answered their table instead of their contract
Credential.Me and Sessions.Me declare no updatedAt. The tables carry one, and pg, MySQL and sqlite
all read those two tables with a bare select() — so every credential and every session came back with a
column the row type says is not there. MySQL's credential rows carried a second: password_key, the
generated column that stands in for the partial index pg and sqlite get.
The MySQL case is the one that shows it was never intended. Its projections open with "the row contract:
each table minus its generated columns, which are index carriers and not fields", and the line under that
comment strips email_norm and username_norm from identities. The next line, for credentials, took every
column — including the generated one the comment is about.
Nothing here leaks a secret, and that is worth stating plainly because it is the first question this
invites. secret never reaches an application: toPublicCredential drops it and allowlists the metadata
by kind, the GDPR export is the only path that hands a credential out, no event payload carries one, and
the server adapters never touch the credential store at all. What escaped is a timestamp and an index
carrier whose two halves, identity_id and tenant_id, are already fields of the row.
The consequence is divergence rather than disclosure. The memory adapter returns exactly the contract, and
it is the adapter the whole unit tier runs against, so the shape was right everywhere it was ever looked at
and wrong on all three backends anyone deploys. It surfaces where keys are enumerated rather than read:
the redis event bus revives every key in DATE_KEYS as a Date, updatedAt among them, so a session
handed to a remote subscriber carries a field on pg, MySQL and sqlite that it never carries on memory.
Each dialect now projects explicitly, the way MySQL already did for identities, and the *Row types in
pg.types.ts, mysql.types.ts and sqlite.types.ts are Omited to match rather than describing the raw
table.
The reason no test saw it is that expectFieldTypes only ever iterates the fields its own spec names. It
is built to catch a declared field of the wrong shape — a Date that came back a string — and an
undeclared field is not something it can look at. The guard was one-directional.
expectExactKeys is the other direction, and expectRow runs both, so the 18 read paths in the compliance
matrix that already asserted field types now also assert the exact key set: no column that is not on the
contract, and none of the contract's missing. The key lists are Object.keys over a
satisfies Record<keyof T, true> literal, so a field added to a row type stops compiling until it is
listed there, and a name that is not on the type is rejected outright.
Both halves are mutation-checked. Putting sqlite's getByHash back to a bare select() fails one unit
test, getByHash keys: expected 15 to deeply equal 14. Putting MySQL's credential projection back to every
column fails one e2e test against real MySQL, create keys: expected 13 to deeply equal 11 — the two
columns named above. Counts are unchanged at 3295 and 723 because these strengthened cases that already
existed rather than adding new ones.
Left alone, deliberately. session.created and session.rotated carry the whole Sessions.Me, so
csrfHash rides out to audit sinks and webhook endpoints, while ExportBlob strips that exact field from
its sessions. It is not forgeable — verification hashes the submitted token and compares, so a leaked
digest buys an attacker a sha-256 preimage and nothing else — but the two paths disagree about whether the
field may leave, and picking which one is right changes a published event payload.
A session patch could move the row out from under the cookie that opened it
Sessions.Store.update(id, patch) types its patch as Partial<Me>, and Me includes id. That id is not
a surrogate key: it is the hash of the token in the caller's cookie, which is why create refuses a second
write to one rather than treating it as an update.
The memory store pins it — "id is pinned because the key is the sid hash the cookie carries" — and so
does the redis store, whose own line reads "id is pinned to the key the row lives under, and undefined
means 'leave this alone', as in the memory and SQL stores". That last clause was the bug. pg, MySQL and
sqlite passed the patch into set() whole, so a patch naming id rewrote the primary key.
Same call, two behaviours, decided by which backend is configured. On memory, redis and valkey it is a no-op. On the three SQL dialects the row moves to a hash nobody was ever issued: the cookie the caller holds now reaches nothing, which reads as a silent sign-out, and the session answers to whatever hash the patch supplied.
It was not reachable through the engine, which is worth stating: the only two internal callers pass fixed
literals, { aal, fresh } on step-up and { expiresAt, fresh } on refresh. What makes it worth fixing
anyway is that Sessions.Store is a published contract — an application holding the store directly, which
the @gentleduck/auth/adapters entry exists to support, gets whichever of the two behaviours its database
happens to give it.
All three dialects now drop id before building the SET clause. A patch naming it is ignored rather than
refused, because update is the refresh path and a caller spreading a row it just read must not start
failing. The compliance case asserts all of it — the answer keeps the original id, the rest of the patch
still applies, the original hash still resolves and the supplied one does not — so neither a move nor a
refusal passes. Reverting the pin fails it on sqlite in the unit tier and on real Postgres in e2e.
The one repoint that mattered was pinned only where it was hard
update may legitimately change identityId; that is how a session moves between accounts. The SQL stores
get the consequences for free, because everything downstream filters the column. Redis does not: it keeps a
set per identity and a scored expiry member carrying the identity, and it moves the session between them by
hand, under a note that a session left behind in the old owner's index is one "sign out everywhere" cannot
reach.
That work was pinned by redis's own tests and nowhere else, so the contract never said a repointed
session follows its new owner — only one implementation's test suite did. It is in the shared matrix now:
after the repoint the session is listed under the new identity and not the old, and
deleteAllForIdentity(new) reaches it. Deleting redis's index move fails the case on redis and valkey, and
the four adapters that had it structurally keep it deliberately.
Two of the three schemas could say anything without a test noticing
The e2e suites do not create their tables from the drizzle schema. They apply a committed .sql that
bun run e2e:schema generates from it, and nothing regenerates that file: no CI step runs the script, and
no CI step checks the output is current. The only thing holding the schema and the database together is a
developer remembering a sentence in a comment.
sqlite had a guard, and its docblock states the problem exactly — "a constraint could be deleted from the schema and the whole matrix would go on passing against a file nobody regenerated". It also names the sharper half: drizzle evaluates a table's extra-config block lazily, so without a test that forces it, nothing runs the code declaring those checks, indexes and foreign keys at all.
Neither pg nor MySQL had one, which is the wrong two to leave out. sqlite's suites run in the unit tier and give an answer in seconds; pg and MySQL run only behind docker, in the tier that gets run least.
It is demonstrable rather than theoretical. Adding check(kind <> 'api-key') to the MySQL schema — a
constraint that would reject every api-key row in the table — left all 731 e2e tests passing against a real
MySQL, because the constraint never reached the database and nothing compared the two. The identical edit
to the sqlite schema fails two tests immediately.
schema-drift.ts carries the shared half and there is now a schema test per dialect. They need no database,
so pg and MySQL drift is caught in the unit tier alongside sqlite's rather than never. Both directions are
mutation-checked: a constraint added to the MySQL schema without regenerating now fails with
``chk_zz_demo_never_applied is declared but never reached the generated DDL, and a check deleted from the
pg schema while the DDL keeps it fails the opposite assertion.
The DDL is not stale today — regenerating all six files produced byte-identical output. What was missing was anything that keeps it that way.
Checked and clean, recorded so the next pass skips them. The version counter moves identically on
every mutator across memory and the SQL dialects — create, rotate, patchMetadata, an empty patchMetadata,
link, a repeated link, unlink, an absent unlink, softDelete and restore all agree — with the single
exception of revoking an already-revoked credential, which is the divergence already open above. And
MySQL's asciiKey/binKey really are binary collations, ascii_bin and utf8mb4_bin, so the case- and
accent-folding hazard their own SECURITY note describes is genuinely closed: a secret or a session id
compares byte-for-byte there exactly as it does on pg and sqlite.
The store every unit test runs on enforced none of its table's rules
auth_credentials is the most constrained table in the schema: a kind from a fixed list, a secret that is
not blank, a tenant that is not the empty string, an expiry no earlier than the row it belongs to, and one
password per identity per tenant. The memory adapter has no schema, and it checked none of them.
Five writes were accepted by memory and refused by all three SQL dialects: a second password for one
identity, a blank secret, an empty-string tenant, an expiry preceding createdAt, and a kind the contract
does not name. Every unit test in the package runs on memory, so a write reaching a database for the first
time in production is where these were being found.
Two live instances were already in the repo's own tests. ApiKeysFacet.create accepts a past expiresAt
and chk_auth_credentials_expires_after_created refuses that write on every dialect, so
{ expiresAt: Date.now() - 1 } is a key that can be created in dev and cannot be created in production; a
totp fixture planted a non-string secret the same way. Both now plant past the write path with
adapter.raw, which is how a corrupt row actually arrives — no database produces one through create.
Memory now applies the same rules and raises the same codes, and the two new compliance cases bind all four
credential backends. That they pass unmodified against real Postgres and MySQL is the check that they
describe the databases rather than the double: AUTH_ALREADY_EXISTS for the second password,
AUTH_INVALID_PARAMETERS for the rest. Dropping either rule from memory fails its case; dropping both
password unique indexes from the sqlite DDL fails the same case there. The unknown-kind rule is enforced but
not pinned, because naming a kind outside Credential.Kind in a test needs a cast to get past the compiler
that already forbids it everywhere real.
Not part of this. A dropped deleteByKind in PasswordsProvider.set was already caught by its own
test, which asserts the identity holds one password row afterwards. The gap was the store contract, not
that path.
Redis took session writes it would never read back
parseStoredSession is deliberately strict, and says so: an unknown kind or an aal outside 1–3 rejects
the whole row rather than defaulting it, because "dropping it hands back an aal: 2 session with no
factors that step-up reads as authoritative". The write path applied neither check.
So create returned success, the key was written, and every later getByHash answered
AUTH_SESSION_REVOKED — the store disagreeing with itself about the same row. The caller is handed a sid
for a session that was never readable: a cookie that authenticates nothing, with no error on the write that
produced it. update had the same shape, and reached it from a patch rather than a create. Valkey wraps
RedisSessionImpl, so it carried the bug too.
This is the production half of the divergence the previous entry found on credentials. Memory accepted the
same writes and read them back, which is permissive but at least consistent; sqlite, pg and MySQL refuse
them at the write with AUTH_INVALID_PARAMETERS, which is correct. Redis was the only store that said yes
and then lost the row.
assertSessionAllowed now holds the rule once, in sessions.constants.ts, and both schemaless stores apply
it on create and on update, raising what the dialects raise. One compliance case binds all six stores and
asserts the refused patch left the row alone rather than half-writing it; it fails if the guard is dropped
from redis's create, from memory's create, or from redis's update alone. The credential kind rule left
unpinned by the previous entry is pinned too — the suite already casts to plant values a compiler forbids,
which is the only way to reach a guard that exists for JS callers and dynamically built patches.
Measured and deliberately left. Three more session CHECKs have no equivalent in the schemaless stores:
expires_at >= created_at, absolute_expires_at >= expires_at and rotated_at >= created_at. Applying
them breaks 18, 9 and 4 unit fixtures respectively, all of which plant a backwards row to stand in for time
passing. That is worth a decision rather than a quiet rewrite, because some of those tests describe states
a SQL-backed deployment cannot hold: "refuses a session past its absoluteExpiresAt" needs
absoluteExpiresAt behind expiresAt, which is exactly what
chk_auth_sessions_absolute_expires_after_expires forbids. The gate that reads both deadlines is right to
check both; what is unclear is whether the row it is being tested against can exist.
Release surface
FakeRedis and fakeRedis were exported from @gentleduck/auth/adapters/redis, a production entry, and
shipped in its .js and .d.ts. They are a test double: nothing outside __tests__ imported them. They
now come from @gentleduck/auth/test instead, and the RedisLike type stays on the adapter entry, since an
app implementing the interface needs it and an app reaching for the double is writing a test.
Checked and clean. All 62 export subpaths resolve to built files and the bin does too; no dialect
adapter imports pg, mysql2, better-sqlite3 or ioredis directly, so those stay dev-only and the
caller's drizzle handle carries the driver; no adapter source carries a console.*, debugger or a TODO;
no production module imports from src/test; the published ./test entry pulls in no vitest; and
__isMemoryStore is branded on all four memory stores, which is what assertStrict reads to refuse them
under env: 'production' — note that strict() is opt-in, called by the app at boot.
Left for a decision. src/core/drivers/redis-like.ts opens with "THIS WHOLE FILE IS NOT REVIEWED
NEITHER TRUST BY ME ... it's generated by AI", and src is in files, so that sentence publishes to npm
on the module backing the redis adapter. It is no longer true of the file's coverage — it has its own
redis-like.test.ts and is exercised by 17 test files including the whole redis compliance matrix — but
whether the code is trusted is the author's call, not a thing to quietly delete.
public-surface.test.ts guards only the root barrel, not the other 61 subpaths. That is the gap the
FakeRedis export sat in.
5.10.0
Minor Changes
Follow-ups to the adapter rewrite: factories for every drizzle dialect, provenance on a provider link, and two audit fields the rewrite dropped without saying so.
Factories for the drizzle adapters.
5.9.0leftnew DrizzlePgAdapter(...)as
the only way to build one while memoryAdapter() was still there, so functional-style
config worked for the dev adapter and for none of the ones that ship. Each dialect now
exports drizzlePgAdapter / drizzleMysqlAdapter / drizzleSqliteAdapter, which
mirror the class generics so withClient keeps its type.
There is no separate *Storage form. An adapter already satisfies Engine.Stores
structurally, so stores: drizzlePgAdapter(db) is the whole call. memoryStorage()
and the unused Config.IStorage duplicate of Engine.Stores are gone with it.
auth_identity_providers.added_by. A provider link is a new way to sign in as
that identity, so an admin attaching one and the account holder attaching their own
must not read alike - the same argument that put actor_id on auth_events.
Nullable, stamped from the ambient actor on both create and link, in all four
adapters, and readable as ProviderLink.addedBy. No updated_at/updated_by:
provider_id and provider_sub are the row's identity, so changing either is a
different link, which is why the column is added_at and not created_at. No soft
delete either - a hidden link would still hold uq_auth_identity_providers_sub and
block that login being attached anywhere else.
Identities.ProviderLinkInput omits addedBy and the facet's create now takes it
rather than ProviderLink, because a caller passing its own would be forging
provenance the ambient actor exists to record.
iamDecisionId is back on identity.impersonated. It was optional on the event
through 5.8.0 and went missing in the rewrite, which never mentioned it. An
impersonation that cannot be traced to the authorization that permitted it is the
one entry an audit log cannot afford to lose, and the consumer writing that row had
no other source for it.
Migration. New nullable column, so existing rows need nothing:
ALTER TABLE auth_identity_providers ADD COLUMN added_by text;
duck-auth migrate emits it.
5.9.0
Minor Changes
- 7228f81: Storage adapters: one class per backend, implementing the engine's store
contracts directly, plus a
.wrap()escape hatch on every adapter call.
The bridge layer is gone
@gentleduck/auth/adapters/sql used to define a second, lower-level contract —
23 operations shaped around a where/patch pair — that each dialect
implemented and sqlStores then translated into the four stores the engine
actually binds. Two contracts for one job: every method was written twice, and
the translation layer was where the miss-is-null rule, the actor stamping and
the patchMetadata retry lived, invisible to the dialect that produced the row.
Each adapter now implements Identities.Store, Credential.Store,
Sessions.Store and Events.Store itself. What the translation layer held is
either inlined where it belongs or shared as a free helper on the new base.
Removed from @gentleduck/auth/adapters/sql: SqlBridge, SqlStore,
SqlStores, sqlStores, SqlIdentityStore, SqlCredentialStore,
SqlSessionStore, SqlEventStore, asBridge, orNull. The entry point keeps
withSqlEventLog and the JSON-column codecs, which were never part of it, and
is now @gentleduck/auth/adapters/drizzle: with the bridge gone there is no
dialect-agnostic SQL contract left for the old name to describe, and everything
behind it is shared only by the three drizzle dialects.
Renamed, with the two statics collapsed into one:
| before | after |
|---|---|
DrizzlePgBridge.bridge(db) / .storage(url) | new DrizzlePgAdapter(db | url | pool) |
DrizzleMysqlBridge.bridge(db) / .storage(url) | new DrizzleMysqlAdapter(db | url | pool) |
DrizzleSqliteBridge.bridge(db) / .storage(path) | new DrizzleSqliteAdapter(db | path | client) |
createSqlStores(bridge) | — (an adapter is already the stores) |
There is no open() static: the constructor takes the connection string, the
driver pool or a drizzle handle and resolves it, so new is the only way in.
MemoryIdentityStore, MemorySessionStore, MemoryCredentialStore and
MemoryOrgStore are no longer exported separately: MemoryAdapter is one class
on the same base, with the same four facets. memoryAdapter(), memoryStorage()
and MemoryAdapter#raw are unchanged.
One base class, one contract
@gentleduck/auth's adapter layer is now two declarations. Adapter is the
contract and nothing else: the four stores, the Answer<T> a call returns and
the Result<T> its wrap() hands back. AdapterStore is the class every
adapter extends: it takes its driver's error mapper and exposes run, the one
place a failure is typed and wrap is attached.
asAdapter is gone. It checked at runtime that an adapter had all four stores —
something implements Adapter.Me already proves at compile time — and used that
check to launder the profile type. DrizzlePgBridge.storage<MyProfile>(db) is now
new DrizzlePgAdapter(db): an adapter answers the profile shape its table
actually declares, so an app whose profile is narrower than that is told so by
the compiler rather than trusted at runtime.
answering(promise) is now AdapterStore.answer(promise), and LOG_PAGE,
stamped and rowWithLinks moved to @gentleduck/auth/adapters/drizzle, next to
the schemas that use them.
A store keeps two of its six set-based writes
Identities.Store declared six optional set-based writes alongside the
single-row ones — softDeleteMany, restoreMany, eraseMany,
updateProfileMany, linkMany, unlinkMany — as a fast path a store could
implement instead of being looped over. Four of them were real: every SQL
dialect implemented softDeleteManyReturningIds, eraseManyReturningIds,
restoreManyReturning and updateProfileManyReturning, and the sql bridge
wired them onto the contract. linkMany and unlinkMany are the two nobody
ever implemented.
softDeleteMany and eraseMany stay on the contract, and pg, mysql, sqlite and
memory all implement them. Both are one statement that reports which of the
named ids it touched, which is the whole of their per-row semantics — there is
nothing for a dialect to get wrong that RETURNING does not already answer. The
loop they replace costs two round trips per id for a soft delete, because a
softDelete answering null cannot be told from one that matched nothing
without a read in front of it.
restoreMany and updateProfileMany are gone from the contract and are facet
loops now. Their per-row refusals — a closed grace window, an address a live row
has taken since, a lost version race — were reimplemented once per dialect
behind the old bridge, and a statement that reports which ids it touched cannot
say which of those applied to the rest. The facet decides that once.
AuthIdentities#softDeleteMany and its five siblings are unchanged to a caller:
one outcome per input row, in input order, each refusal named the way it was
before.
Sessions keep theirs, and every store implements them
Sessions.Store kept three optional set-based forms —
deleteAllForIdentities, deleteMany and listByIdentities — and for a while
shared the identity side's problem: declared, consumed by the facet, implemented
by nobody, so revokeAllForIdentities over 500 people was about a thousand round
trips on Postgres where it should have been two statements. That is the one batch
path with a caller that matters — "sign this whole org out" — so the answer here
is the other one: every adapter implements all three.
pg and sqlite delete the set in one statement and read back the ids that matched;
mysql has no RETURNING, so the ids are selected under their own lock first, the
same read-then-delete _erase already does for credentials; memory makes one pass
over the map instead of one per identity; redis batches the record delete, the
expiry zrem and the per-identity srem into three round trips for the whole set
rather than three per identity.
outcomesFromAffected is shared from core/batch — the ids a statement affected
become one outcome per requested id, and an id that did not come back matched no
row, which is the only thing one statement can say about it.
RedisLike.Client gains an optional mget. listByIdentity backs the
active-devices screen and runs inside every revokeAllForIdentity, and it was
issuing one GET per session; it now reads them all in one round trip, falling
back to concurrent gets for a client without the command.
Credential.IStore.deleteByIdentities is gone. It had no implementation and,
unlike the session forms, no caller either — nothing in the package ever invoked
it, so it was a declaration and a compliance case and nothing else.
.wrap() — a failure as a value
Every adapter call now answers an Adapter.Answer<T>, a native promise with one
extra method:
const { data, error } = await storage.identities.find({ email }).wrap();
if (error) return reply(error.code);
error is the discriminant and is always an AuthError, never unknown: a
non-Error a driver throws becomes the cause of a real one rather than being
handed back raw. Answer<T> extends Promise<T>, so awaiting it throws exactly
as before and every existing caller — Promise.all included — is untouched.
Scope: wrap() catches what the call rejects with. An argument that throws
before there is a promise still throws at the call site; a bug in the call is not
a failure of the work.
error also carries the codes that call can raise, not a flat AuthError:
const { error } = await storage.sessions.update(id, patch).wrap();
// ^? AuthError<'AUTH_SESSION_REVOKED' | Adapter.Faults> | null
Adapter.Faults is what any call can raise — the whole range of a driver's
error mapper, since any statement can meet a constraint, a dead socket or a
missing schema. Adapter.Me names what each method adds on top: restore can
answer AUTH_GRACE_EXPIRED, patchMetadata and upsert
AUTH_CREDENTIAL_NOT_FOUND, update AUTH_SESSION_REVOKED. The union is
closed, so a switch on error.code is exhaustive, and comparing a read's
error against a code it cannot raise no longer compiles.
AdapterStore.answer(promise) now types the failure on the await path too, as
run always did, and attaches wrap to a promise of its own rather than to the
caller's.
Each adapter declares its facets as their slot in Adapter.Me rather than
checking an inferred object against the contract with satisfies. A satisfies
only proves the body is assignable, so the type a caller saw came from the body —
run's Adapter.Faults and nothing else — and the extra codes above were
declared where no call site could read them. storage.identities.restore(id)
now answers AuthError<'AUTH_GRACE_EXPIRED' | Adapter.Faults>, which is what
this section always said it did.
Fixes this turned up
mergere-pointed the duplicate's credentials, sessions and logins and only then discovered the survivor was gone, leaving the duplicate destroyed for nothing. Both sides are confirmed before anything moves, on all three dialects.linkagainst an identity that no longer exists surfaced the foreign-key violation asAUTH_IDENTITY_NOT_FOUND; it now answersnull, as every other read of a missing row does.- A soft delete no longer takes
deletedAt/deletedByfrom the caller: the window is a grace period in milliseconds and the actor is the ambient one, so two dialects can no longer disagree about when a row becomes purgeable. - An empty session patch is a no-op rather than a driver syntax error, and a
missing session answers
AUTH_SESSION_REVOKEDon every backend. patchMetadataon a row that is not there (or not this tenant's) answersAUTH_CREDENTIAL_NOT_FOUNDrather than telling the caller to retry.
One read per adapter, and no handle to pass
Each dialect had a private read taking the client to read through, so all ten of
its call sites passed this._db to say "the usual one". The read now uses the
adapter's own handle, and a transaction binds an adapter to its tx —
this.withClient(tx) — so nothing is threaded anywhere and no call site can
quietly read outside the transaction it is in.
MySQL had three row helpers where the others had one: a locked re-read, a links read and an assembler over both. It now answers every lookup through the same single join the other two dialects use, leaving one helper — the lock a write takes before it re-reads.
On Postgres, create and erase are one statement each rather than a
transaction around two. The row, its logins and the read that answers them
travel as one CTE, with the links taking the id straight out of it; erase
reads the logins in the same with that deletes their owner, since every arm
of one sees the snapshot the statement opened on. Four round trips become one,
and the row an erase answers with now carries the logins the cascade took —
proven for every adapter, not just this one.
One withClient, on the adapter
A transaction was joined store by store: every store contract carried its own
optional withClient, each dialect implemented four one-line delegates back to
the adapter's own, and withTransaction called them one at a time. The facets
come off one adapter and share its connection, so that was four answers to one
question.
withClient belongs to the adapter now — Adapter.Me declares it, the three
drizzle adapters already had it, and withTransaction makes the one call. The
capability hook follows: MfaFacet and ApiKeysFacet are handed the bound
facets instead of a driver handle to rebind a store of their own from, so
Capability.withClient(stores, events) replaces (client, events) and neither
can answer null any more.
Hand the engine the adapter — stores: adapter — and transactions work. A bag
assembled facet by facet (redis sessions beside a drizzle identities store, say)
has no withClient, and withTransaction throws AUTH_MISCONFIGURED saying so
rather than leaving a write outside the caller's transaction. createTest takes
the same shape: one stores override in place of four per-facet ones.
Fewer statements per call
What a store call costs on a real database is mostly the number of statements it sends: each one is a round trip the caller waits through. Four shapes sent more than they had to, on every dialect that had them.
link read the identity to check it was there, inserted the login, then read the
identity again to answer. The first read is gone — the insert takes its row from
auth_identities, so an identity that is gone or hidden writes nothing and the
read that answers returns null on its own. MySQL also held a locked read and a
second read for "does this identity already hold this provider", both inside a
transaction; the repeat is settled in the same where now. Three statements to
two on Postgres and SQLite, six to two on MySQL, and linking to a soft-deleted
identity no longer writes a row on MySQL that the answer then hides.
patchMetadata read the row, merged in JS, wrote against the version it had read
and retried once when a concurrent write took that version first. It is one
update now — metadata || $patch::jsonb on Postgres, json_set on SQLite and
MySQL — so the merge happens under the row's own lock and there is no window to
lose. Not json_patch/json_merge_patch: those are RFC 7396, where a null value
removes the key rather than storing it, which is not what the object spread the
memory adapter runs does.
merge read the survivor's credential kinds and provider ids to decide what the
duplicate could keep. Both reads are now conditions on the writes that used them,
and the logins that clash stay where they are for the cascade under the final
delete to take, rather than being cleared by a statement of their own.
MySQL took a select … for update before update, softDelete, rotate,
revoke and patchMetadata to learn whether the row was there at the version
expected. The write takes the same lock and affectedRows answers the same
question, so the probe is gone from all five, and restore no longer reads the
row twice. erase keeps its locked read: it has to answer with the row it
removed, so that read comes before the delete either way.
The memory adapter's create checked the address, the handle and each login in a
pass of its own; it is one scan now, which is also the one place the three unique
indexes every dialect carries are written down together.
One fix fell out of it: patchMetadata could raise AUTH_STALE_WRITE after
losing the version twice, a code its own fault union does not name and a retry
its caller could not act on. There is no version to lose now, so the only failure
left is the AUTH_CREDENTIAL_NOT_FOUND the contract declares.
bun run bench:adapters times all 26 store calls on all four adapters. The
before and after tables, per call, are in src/adapters/README.md.
An identity store answers one read. findById, findByEmail and
findByProviderSub are find(by), named with { id }, { email } or
{ providerId, providerSub } — three names for one join, one live filter and
one order, where only the condition ever differed. auth.identities.getById
and its siblings are unchanged; it is stores that change shape, so
stores.identities.findById(id) is now stores.identities.find({ id }).
The where clauses that scope a read to a tenant were each a mutable array and
a pair of ifs; they are one and(...) with a shared inTenant filter that
drops out when the caller named no tenant.
MySQL read an identity by address 50x slower than by any other key. The
where matched lower(profile ->> '$.email') while the unique index lives on
the email_norm column generated from that expression, and MySQL will not use
a generated-column index when the comparison carries the connection's
collation - so every lookup scanned the table. It matches the stored column
now: 24 ms to 0.28 ms over 50k rows. No test could see it, since every
assertion passed either way on a table of ten rows.
bun run bench:adapters is what found it: the same 50k identities and 100k
logins on all four adapters, timed at the adapter boundary. The numbers, the
plans behind them and the method are in src/adapters/README.md.
One way a driver failure is named
Each dialect had its own translator: the same five makers, the same socket-code
list and its own four-deep cause-chain walker, restated three times, with a
Make that took one argument in two of them and two in the third.
There is now one reader — signalOf — and one resolver, and each dialect is
only its own table of what its driver says: the text it names the refused index
in, the code or errno it gives, the family that code belongs to, read in that
order. 256 lines became 189, and nothing an adapter answers changed.
AuthError itself no longer declares each code's HTTP status twice — once as a
literal on the union member and once in the status table that is the only one
ever read — and the class is free of type assertions: the origin argument is
narrowed by a predicate, and toJSON scrubs through a function that returns a
record rather than one that is cast to it.
5.8.0
Minor Changes
Add an optional post-authentication hook to the NestJS
nestSignInhandler.nestSignInwas sealed: it ran CSRF, body parsing, the sign-in flow and intent execution
with no seam between authenticating and responding, and forward/executeIntents are
module-private. An app that had to refuse a sign-in on something the engine cannot know —
a host allow-list, a suspended tenant, a per-identity block — had no way to do it without
reimplementing the whole handler.
nestSignIn(auth, {
onAuthenticated: (outcome, req) =>
outcome.session?.identityId
? denialFor(outcome.session.identityId)
: undefined,
});
The hook runs after the credentials verify and the session row exists, before any intent
reaches the response. Returning a NestSignInDenial revokes the session that was just
created and answers with the error instead, so the client never receives the SID.
Revoking is the library's job and not the caller's, deliberately: nestSignIn owns the sid,
and a consumer who forgot the revoke would leave a live session sitting behind a 403 — a
silent auth bypass. For the same reason a hook that throws also revokes, and a denial the
adapter cannot read (a blank code, or a 2xx status that would render the refusal as a
success) still denies, at 403.
Gating here rather than in front of signIn also means a caller has to prove the password
before it learns anything, so a denial is not an enumeration oracle.
nestSignIn(auth) is unchanged — the options argument is optional and every existing call
site keeps its current behaviour. Adds the NestSignInDenial and NestSignInOptions types
to @gentleduck/auth/server/nestjs.
5.7.1
Patch Changes
Export
StoredProviderLinkandStoredFactorfrom the drizzle entrypoints, so a consumer can name the types the tables now infer.5.7.0 moved the JSON columns onto
customType, which put those two names into
authIdentities.$inferSelect and authSessions.$inferSelect. They were only
reachable through an internal bundle chunk, so anything that infers a type from a
table rather than annotating it — a repository base class, a generic wrapper,
getTableColumns — failed to compile:
TS2883: The inferred type of 'AuthIdentitiesRepository' cannot be named without a
reference to '.../dist/index-CRY_TusL.cjs'. This is likely not portable.
A type annotation is necessary.
Both types are now re-exported from @gentleduck/auth/adapters/drizzle/pg, /mysql
and /sqlite alongside the tables they describe, which is the module a consumer
already imports. They remain available from @gentleduck/auth/adapters/sql too.
Types only — no runtime change, and nothing to migrate.
5.7.0
Minor Changes
Framework adapters now bind the request's actor scope, so writes carry provenance.
withActor,runWithAuditEnvelopeandsetDefaultActorResolverall existed,
and events.audit.ts documented the intended adapter wrap in its own docblock —
but no adapter shipped it. A grep for withActor across src/server/ returned
nothing, so created_by / updated_by / deleted_by and
Events.Envelope.actorId were null on every write a request drove, whether or
not the request was authenticated.
New, one per adapter:
- middleware, for frameworks that compose a
next—expressActorContext(auth),koaActorContext(auth),honoActorContext(auth),nestActorContext(auth) - handler wrappers, for those that do not —
nextWithActor(auth, handler),fastifyWithActor(auth, handler),elysiaWithActor(auth, handler) withGrpcbinds internally; it already had the resolved session in hand
The primitives are exported from @gentleduck/auth for anything hand-rolled:
withRequestActor(auth, req, fn), withResolvedActor(session, fn) and
actorForSession(session).
While impersonating, the actor is the operator behind actingAs, not the account
being acted on: the subject is already the row being written, and the column
exists to name the human accountable. That id reached audit events through
auditEnvelopeFor and never reached the provenance columns.
An anonymous request, and one whose session will not resolve, both run unbound —
a stale cookie on a public route must not become a 500, and refusing it is a
guard's job. Both leave the actor null, which stays distinct from "the user did
this themselves". nestActorContext reuses the session makeGuard resolved, so
the pair costs one resolveSession rather than two.
Adapters can now check the request fingerprint, not just stamp it at sign-in.
hijack.evaluateandresolveSession'srequestSnapshotboth existed, and the hijack policy
(onIpChange, onUserAgentChange) could be configured, but no adapter read an IP or a
User-Agent on the resolve path - so the pair recorded on the session was written at sign-in and
never looked at again. The policy had no effect and the anomaly detectors never ran.
Following the shape duck-iam uses for its request environment, each actor-context wrapper now
takes an optional getCaller, and each adapter exports the reader to pass it:
import {
expressActorContext,
expressCaller,
} from "@gentleduck/auth/server/express";
app.use(expressActorContext(auth, { getCaller: expressCaller }));
expressCaller,koaCaller,fastifyCaller,nestCaller,elysiaCaller,honoCaller,nextCallerread what the framework resolved and never a forwarded header - the host is the only layer that knows how many proxies it trusts.nextCallerreports the User-Agent only, as a WebRequestcarries no resolved peer address.- Supplying
getCallerforwards anAnomaly.RequestSnapshottoresolveSessionand compares the fingerprint withhijack.evaluate, which emitssuspiciouson any drift even when the configured reaction is'ignore'. onHijackhandles drift instead of the configured reaction, and is where'rotate'belongs: rotating writes a new session cookie onto the response, which a wrapper that only owns handler execution cannot do. Without it,'mfa'and'revoke'throw and'rotate'is audit-only.
This is opt-in. Omit getCaller and the wrappers stay what they were - an attribution scope that
refuses nothing - because switching it on starts acting on IP and User-Agent drift for sessions
that were already issued.
Two fixes fall out of the same work:
callerContextnow truncates to the lengthsSessionsImpl.createactually stores (exported asSESSION_COLUMN_CAPS: 64 forip, 512 foruserAgent, 256 forfingerprint). Normalising to a different length than the row would make a client with a long User-Agent compare unequal to its own stored value on every request - a permanent step-up loop.The Nest adapter's private
callerOfis folded intonestCaller, so its sign-in route normalises its fingerprint the same way every other adapter does.Breaking, in a minor. Drizzle tables now hand back the types they declare for their JSON columns.
$inferSelectonauthIdentitiesandauthSessionschanges shape, and the runtime values change with it, so anything reading these tables directly needs a look before upgrading — the version number will not warn you, this note is the warning.authIdentities.providers,authSessions.factorsandauthSessions.actingAs
carried .$type<T>() annotations naming Date — a compile-time assertion and
nothing else. What the driver actually returned was the ISO string
JSON.stringify had written, so a direct db.select().from(authIdentities) —
a supported read, since the tables are a public export — gave
providers[0].addedAt.getTime is not a function, and addedAt < new Date()
was quietly always false. Revival existed, but only inside createSqlStores,
which a direct table select never reaches.
Each of the three columns is now a customType whose fromDriver does the
parsing. The SQL type is unchanged (jsonb / json / text), so this is not
a migration. Unreadable values fail closed rather than becoming an
Invalid Date: addedAt and completedAt read as null at the column and
fall back to the row's own createdAt in the store, which is the layer that
can see it; an actingAs whose window cannot be read is dropped.
Two latent bugs surfaced with the types: the mysql bridge's listByIdentities
and every sqlite session read returned rows without reviving them at all, and
the pg and mysql bridges revived dates with new Date(value as string), which
turns corrupt input into an Invalid Date that satisfies instanceof Date and
compares false against everything.
What breaks, and how to migrate
typeof authIdentities.$inferSelect used to say providers: ProviderLink[] with
addedAt: Date while the value was a string. It now says StoredProviderLink[]
with addedAt: Date | null, and the value matches. authSessions.factors moves
the same way (StoredFactor, completedAt: Date | null), and actingAs is now
Sessions.ActingAs | null.
Two shapes to look for:
- A workaround that re-typed the column as a string. The honest thing to write
against the old behaviour was
Omit<typeof authIdentities.$inferSelect, 'providers'> & { providers: { addedAt: string, ... }[] }. That is now wrong in the other direction, and because it is anOmitand replace rather than a narrowing it keeps compiling: the type saysstring, the value is aDate, and nothing reports it. Drop the override and take the table's own type. - Code that assumed
addedAt/completedAtis always present. They areDate | nullat the column now, because an unreadable value reads asnullrather than as anInvalid Date. Read rows through the store, or through the exportedreviveIdentityRow/reviveSessionRow, and the fallback to the row'screatedAtputs a realDateback.
Anything going through createSqlStores or the drizzle*Storage helpers is
unaffected — those already revived, which is why the mismatch stayed hidden.
The parsers are exported from @gentleduck/auth/adapters/sql (storedDate,
parseProviders, parseFactors, parseActingAs, fromJsonColumn,
isProviderLink, isFactor, reviveIdentityRow, reviveSessionRow, and the
StoredProviderLink / StoredFactor types), and the wire-side converters from
@gentleduck/auth/client/vanilla (reviveSession, reviveIdentity,
reviveSessionResult), so an app reading these tables or calling /session
directly can use the same conversion the adapters do instead of hand-rolling one.
5.6.0
Minor Changes
The client hands back the
Dates its types promise, and the wire shape is now written down so it cannot drift again.Response.jsonisJSON.stringify, so everyDateon a session or identity row left the server as an ISO string.Sessions.MeandIdentities.Medeclare all of them asDate, and a cast at the end ofgetSessionstoppedtscfrom noticing.session.expiresAt.getTime()threw;session.expiresAt < new Date()compared a string to a Date and was alwaysfalse, so a session never looked expired.getSessionnow revivescreatedAt,rotatedAt,expiresAt,absoluteExpiresAt,factors[].completedAt,actingAs.startedAt/.expiresAt,createdAt/updatedAt/deletedAtandproviders[].addedAt. The react, vue, solid and svelte clients all wrap this one, so they inherit it.Fields are named, not sniffed by shape:
identity.profileis the consumer's own data and is left untouched. A value that will not parse stays the string it arrived as rather than becoming anInvalid Date, which would satisfyinstanceof Dateand comparefalseagainst everything.New:
VanillaClient.Serialized<T>,SerializedIdentityandSerializedSessionResultdescribe a row as it actually crosses HTTP.Serialized<T>is not assignable toT, so revival is the only route between the two and skipping it is a compile error — the previous version was correct only by convention, which is what let this ship.Also fixed on the same path: an enveloped-free
200with an empty body parses tonull, and thatnullwas returned asdataunder a signature promising aSessionResult. It now resolves to{ session: null, identity: null }.The OpenAPI
Sessionschema had the same disagreement from the other side:expiresAt,absoluteExpiresAtandfactors[].completedAtwere documented asintegerwhen the handler sends ISO strings, so every generated client typed themnumberand a response validator would reject a valid reply. They are nowstring/format: date-time,createdAtandrotatedAtare documented too, andtenantIdis['string','null']- it isnullon every session of a single-tenant deployment. A test serialises a real row and checks each documented field against it, so the spec cannot drift from the wire again.
Patch Changes
Type-consistency pass: the package now reads untyped shapes one way, and the assertions that were left are the two that cannot be avoided.
Three dead casts in the drizzle adapters.
drizzleSqliteStorageanddrizzleMysqlStoragelaundered their bridge withas unknown as SqlBridge.Me<Profile>and pg used a singleas, under a comment claiming the cast asserted the concreteProfileshape. It never did:createDrizzle*Bridgeis already generic and already returnsSqlBridge.Me<Profile>. All three now pass the type argument explicitly and assert nothing, so a future divergence between a bridge and the store contract is a compile error instead of being swallowed - which is exactly what a double cast had been positioned to hide.mfa.tsread credential metadata three ways in one file.verifyTotpandhasTotpused the sharedisProfileBooleanTruereader;confirmTotpEnrollment, the TOTP replay guard and the AAL-3 passkey check castmetadatastraight to an interface. The casts happened to be safe - each compared strictly - except the replay guard, which readlastTotpStepthroughtypeof x === 'number'. That acceptsNaN, andstep <= NaNisfalse, so aNaNthere would have waved a TOTP replay through. All three now use the shared readers.New:
getProfileNumberandisProfileBooleanFalsealongside the existing credential predicates.getProfileNumberrejectsNaNandInfinityrather than returning a number that loses every comparison.isProfileBooleanFalseis deliberately not the negation ofisProfileBooleanTrue- an absent key is neither, which is what tells "explicitly not yet confirmed" apart from "never had the field".The compliance harness had 68 identical casts.
runIdentityStoreComplianceis generic overP, so a profile literal was not assignable to it and every fixture wrote{ email, username } as unknown as P. Every adapter instantiates the suite with the same two keys and the suite exercises no others, so that is now one documented assertion in one helper instead of sixty-eight scattered ones.Outside two dynamic
import()calls for optional peer dependencies, noas unknown asremains in the package. Production code has noanyand no@ts-expect-error.
5.5.1
Minor Changes
Audit round: captcha reaches the engine, three config keys stop being dropped on the floor, and the client stops handing back strings where the types promise
Date.Every entry below this one in the release notes came out of the same sweep. The shape that
recurs: something the type system already promised, that no code path actually delivered — a
config key copied across by hand and forgotten, an option declared on the wrong half of an
interface, a field a projection never populated, a Date that survived JSON.stringify as a
string. None of it failed loudly, which is why it lasted.
Two signatures gained required arguments. beginPasskeyRegistration now takes
credentialStore and tenant — what it needs to read the identity's live passkeys and send
excludeCredentials; completePasskeyRegistration already took both. authRefreshoauthToken
now requires the identities probe, so a refresh token cannot outlive the identity behind it.
Both are a compile error to miss, and both arguments are already on hand wherever the library
itself calls them.
The captcha default refuses rather than passes, so a host that never configured one sees no behaviour change unless it was relying on an unconfigured verifier to succeed — which is the thing being fixed.
cancelAccountDeletiongains the user's route: a single-use undo token.Until now it had exactly one gate — a mandatory
authorize(identityId)callback. That is
the operator's route. A user clicking "undo" in their mail has the mail and no admin
rights, so the grace window that completeAccountDeletion advertises as restorableUntil
was reachable only by opening a support ticket.
completeAccountDeletion now mints an undo token after the soft delete lands — hashed,
single-use, kind: 'recovery' under purpose: 'account-deletion-cancel', expiring at
exactly restorableUntil. Plaintext comes back once as cancellationToken. Pass the new
optional channels (and channel / callbackPath) and the library mails the undo link
under templateId: 'account-deletion-cancel'; omit them and delivery is yours. A host
that does not want undo simply drops the token — nobody else ever holds the plaintext.
cancelAccountDeletion takes one gate or the other:
await auth.flows.cancelAccountDeletion({ token }); // the user
await auth.flows.cancelAccountDeletion({ identityId, authorize }); // an operator
Both, or neither, is AUTH_MISCONFIGURED. Resolving "both" in favour of one would mean a
bad token silently falling back to a callback that says yes, and which gate applied would
depend on a precedence rule invisible at the call site — the exact ambiguity this function
used to be when it took an id and no gate at all. The token names its own subject, so no
identityId accompanies it and an undo link cannot be pointed at another account.
Breaking.
Flows.AccountDeletionCancelInputis now a union ofAccountDeletionCancelByTokenandAccountDeletionCancelByAuthorize. Existing{ identityId, authorize }callers are unchanged; callers that spread a wider object may need to narrow it.completeAccountDeletionreturns an extracancellationTokenfield and writes one extra credential row per deletion.'account-deletion-cancel'is a seventhrecoverypurpose. Anything readingkind: 'recovery'without filtering onmetadata.purposenow sees one more row — seeRECOVERY_PURPOSES.ec9629b: Provenance: audit columns that the API could not fill are now filled, and the ones that could only ever repeat another column are gone.
auth_identities,auth_credentialsandauth_sessionshave declaredcreated_byandupdated_bysince 5.x. Nothing in the package wrote them and they were not on the row types, so every row in every deployment carried NULL provenance — the schema promised an audit trail the API could not produce.The ambient actor context (mirrors the existing tenant one):
import { withActor } from "@gentleduck/auth"; await withActor(req.user.id, () => auth.identities.update(id, patch, version) );withActor(actorId, fn)binds an actor across awaits.withActor(undefined, fn)clears the scope, the same fencewithTenantprovides.resolveActoronAuthEngineconfig is the process-wide fallback for hosts that already have a request context, so provenance does not depend on remembering to wrap every call. Also settable directly viasetDefaultActorResolver.Precedence is explicit → ambient → configured default →
null. Nothing bound recordsnull, which is true; no'system'placeholder is invented.identities.erase(id, { reason, operatorId })used to acceptoperatorIdand dovoid opts. It now binds it — and only when present, so an omitted one leaves an outer scope intact.
Which tables carry what, and why:
auth_identities—created_by,updated_by, and a newdeleted_by. "Who deleted this account" is the audit question that actually gets asked, anddeleted_atalone could not answer it. Set with the delete marker, cleared byrestore, so the pair never disagrees.auth_credentials—created_by,updated_by. Distinguishes a self-service password reset from an admin one.auth_sessions— both columns dropped. A session's author is itsidentity_id, and the one case where the operator differs is impersonation, whichacting_asalready models. A column that can only repeat another column drifts from it.auth_events— newactor_id.identity_idis the subject; this is the operator. Without it "admin X revoked user Y's session" was indistinguishable from "user Y revoked their own" — the one thing an audit log exists to record.Events.Envelopegained a matchingactorId, stamped onto every audited event.
Uniqueness is now a database guarantee on every dialect, and a typed error everywhere:
- MySQL had none. It cannot index a JSON path directly, so email and username uniqueness was left to an application-layer check while pg and sqlite had real partial indexes. That is not a weaker guarantee, it is no guarantee — the check is read-then-write, so two concurrent signups both saw a free address and both got it.
auth_identitiesnow carries two generatedSTOREDcolumns,email_normandusername_norm, that are NULL while a row is soft-deleted; MySQL permits any number of NULLs in a unique index, so this reproduces pg's and sqlite'sWHERE deleted_at IS NULLpartial indexes exactly. MySQL also gained thechk_auth_identities_profile_shapecheck the other two have had since 5.x. - sqlite's unique indexes were declared in a form drizzle-kit could not emit. The index expression
json_extract(profile, '$.email')contains a comma, which the generator splits on to find column names, so it produced DDL naming two nonexistent columns — anyone runningdrizzle-kit push/generateagainst the sqlite schema got no unique index at all. Both indexes now use->>, which SQLite has had since 3.38 and which mirrors what pg already did. The dialect's own email lookups were switched to the same expression so they can still use the index. - A violation of either index now arrives as
AUTH_EMAIL_TAKENor the newAUTH_USERNAME_TAKEN, on every dialect, instead of a raw driver error. The pre-checks in the store are read-then-write and so are racy by construction; the index is what actually decides, and its answer used to surface as a 500 while the pre-check's surfaced as a 409. The driver error stays reachable oncause. - The memory adapter enforced email on
createandrestore, username nowhere, and neither onupdate— so moving an identity onto another live row's email succeeded there and failed on every SQL dialect. All three paths now check both, and the rule moved into the shared store-compliance matrix so no adapter can drift from it again.
Also fixed here:
restoreandrestoreManyno longer resurrect a provider login someone else claimed while the row was hidden.findByProviderSubskips soft-deleted rows, so the sub was genuinely free to take — and restoring past that left two live rows answering to one Google account, with lookups returning whichever the query plan ordered first. Now refused withAUTH_PROVIDER_TAKEN(batch reasonprovider-taken), mirroring the existing email guard. In the batch path the two clashes are checked before either is committed, so a row refused for its address does not go on to reserve its provider subs against later rows in the same call.restoreManynow reports which rule refused each row instead of guessing. It previously labelled every refusalemail-taken, because that was the only clash there was; a provider clash would have arrived under the wrong reason.duck-auth migrateemitted DDL that had drifted from the declared schema:email_verified,created_by,updated_byand bothupdated_atcolumns were missing, so a database built from the CLI could not satisfy the adapter's own writes.duck-auth migratedid not emitauth_eventsat all — the audit log, the one table this release is about, was left for consumers to hand-write. It is now emitted, indexes included.- The CLI emitted an
auth_identities.tenant_idthat no query in any dialect reads or writes. It was declared in the MySQL schema too, and indexed there. Dropped from both: it was a column that existed, filled itself with NULL forever, and read as data — the same defect this release removes fromauth_sessions. - The drift test that was supposed to catch all of this checked one direction over three tables. It now checks both directions over four: a column the schema declares and the CLI omits breaks writes outright, and a column the CLI emits that nothing declares is the quiet one that becomes permanent NULL.
- The sqlite adapter tests built their tables from DDL hand-written inside the test files — no foreign keys, no unique indexes, no checks, plus the same phantom
tenant_id. The suite was passing against a schema no deployment has: session ids that violatechk_auth_sessions_id_length, credentials pointing at identities that do not exist. Both files now build from the generated schema (bun run e2e:schema), and 33 cases that only passed because the constraints were absent were fixed to use real ids and seed their foreign keys.
Breaking:
Identities.MeandCredential.Megain requiredcreatedBy/updatedBy;Identities.Mealso gains requireddeletedBy.Sessions.MelosescreatedBy/updatedByandSessions.CreateInputno longer omits them. Code that builds these rows by hand — fixtures, custom stores — must follow; code that only reads them is unaffected.SqlBridgecustom implementations:softDeleteandsoftDeleteManyReturningIdstake a trailingdeletedBy;restoremust cleardeleted_by;updateProfileManyReturningreceivesupdatedByin the patch. A bridge passing the patch through needs no change; one naming columns by hand, as the Postgres dialect does, must add them.SqlBridge.restoreManyReturningmay now returnrefused: { id, reason }[]alongsidecandidatesandrestored. It is optional so an existing bridge still compiles, but a bridge that guards provider subs and does not report them will have those refusals reported asemail-taken— only the dialect ran both clash queries, so only the dialect can say.- New error codes
AUTH_PROVIDER_TAKEN(409) andAUTH_USERNAME_TAKEN(409), and new batch failure reasonsprovider-takenandusername-taken. Exhaustive switches over either will not compile until extended. - Writes that used to fail with a raw driver error on a duplicate email or username now throw
AuthError. Code matching on the driver's message or error number will no longer match; match oncodeinstead, or readcause. - MySQL only:
auth_identitiesgains two generated columns. They are index carriers, not part of the row contract — a bareSELECT *on that table now returns them, and the adapter excludes them from its own selects. A custom query that spreads the row into anIdentities.Meshould do the same.
Migration. created_by/updated_by on identities and credentials already exist. New: auth_identities.deleted_by, auth_events.actor_id. Droppable: auth_sessions.created_by, auth_sessions.updated_by. Rows written before this release keep their NULL provenance.
ALTER TABLE auth_identities ADD COLUMN deleted_by text;
ALTER TABLE auth_events ADD COLUMN actor_id text;
ALTER TABLE auth_sessions DROP COLUMN created_by, DROP COLUMN updated_by;
ALTER TABLE auth_identities DROP COLUMN tenant_id; -- never read or written
-- "Everything operator X did", on an append-only table that only grows.
CREATE INDEX auth_events_actor_created ON auth_events (actor_id, created_at);
MySQL additionally needs the uniqueness that dialect never had. Resolve any existing duplicates among live rows first — the index build fails otherwise, and that failure is the pre-existing duplicates being reported, not a migration bug:
ALTER TABLE auth_identities
ADD COLUMN email_norm VARCHAR(320)
GENERATED ALWAYS AS (if(deleted_at is null, lower(profile ->> '$.email'), null)) STORED,
ADD COLUMN username_norm VARCHAR(191)
GENERATED ALWAYS AS (if(deleted_at is null, lower(profile ->> '$.username'), null)) STORED,
ADD CONSTRAINT uq_auth_identities_email UNIQUE (email_norm),
ADD CONSTRAINT uq_auth_identities_username UNIQUE (username_norm);
sqlite databases built from a previous drizzle-kit run have no unique indexes
at all — the generator could not emit them. Deduplicate live rows, then:
CREATE UNIQUE INDEX uq_auth_identities_email
ON auth_identities ((lower(profile ->> '$.email'))) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_auth_identities_username
ON auth_identities ((lower(profile ->> '$.username'))) WHERE deleted_at IS NULL;
b06c50b:
cancelAccountDeletionrequires anauthorizecallback.It checked that
identityIdwas a plausible string and then restored the account. No token, no session, no callback. Anyone who could reach the function un-deleted any account by id — including one deleted deliberately, by a user who wanted it gone.Every sibling in that flow is gated:
completeAccountDeletionrequires a single-use token,impersonaterefuses to run without anauthorizecallback. This one was gated by nothing, and its own docstring called it an "optional admin cancel route" — an assumption the signature neither stated nor enforced.await auth.flows.cancelAccountDeletion({ identityId, authorize: async (id) => currentUser.isAdmin && currentUser.canRestore(id), });Mandatory, not optional. There is no default the library could pick that is safe; only the host knows who is asking. Required in the type, so every existing call site fails to compile until it says who may cancel.
Checked before any read or write. A refusal never reaches the store, so it cannot be observed as a restore-then-undo or timed against one.
A refusal reports
AUTH_UNAUTHENTICATED— the same code an unknown identity gets. Distinguishing them would turn the endpoint into a way to ask which accounts are sitting in the deletion grace window.A missing callback is
AUTH_MISCONFIGURED. TypeScript refuses the call, but a JavaScript host — or an options object built from parsed input — reaches it anyway, and a missing gate must not read as permission granted.The callback receives the id, not the identity: the row is soft-deleted at that point and cannot be loaded. Resolve the caller from your own request context.
Breaking: Flows.AccountDeletionCancelInput gains a required authorize: (identityId: string) => Promise<boolean>. Every call site must supply it.
Not covered: a cancellation token, for a user clicking "undo" in an email without admin rights. Nothing issues one today — requestAccountDeletion mints a deletion token that completeAccountDeletion consumes — so that path needs a second credential with its own TTL, channel and template. Until it exists, a user-facing undo link is the host's to authorize like any other route.
auth.captchaexists, and refuses instead of passing when it was never configured.Engine.Cfghad nocaptchakey at all, so a host that wanted a challenge kept a second
verifier next to the engine with its own wiring, and auth.captcha was undefined. It is
now a first-class member: createAuth({ captcha: authTurnstileVerifier({ secret }) }), read
back as auth.captcha.
Unconfigured, auth.captcha is an AuthUnconfiguredCaptchaVerifier — every call answers
{ success: false, errorCodes: ['captcha-not-configured'] }. Deliberately not
AuthNullCaptchaVerifier, which always passes and ships for tests: a host writes
if (!(await auth.captcha.verify(...)).success) throw, deploys with the secret unset, and an
always-pass default lets every bot through while the code reads as though a captcha is
enforced. Pass authNullCaptchaVerifier() to opt into always-pass. Both, plus the
Turnstile / hCaptcha / reCAPTCHA v3 verifiers, are exported from @gentleduck/auth/core.
Also fixed: createAuth copied engine-config keys across by hand and dropped the ones
nobody remembered to add. AuthDefine.Cfg inherits them from Engine.Cfg, so a dropped key
still type-checks at the call site and then silently does nothing. resolveActor was one:
a host that wired an actor resolver the documented way wrote created_by / updated_by /
deleted_by as NULL, which is the audit trail going missing for exactly the caller who took
the trouble to configure one. captcha would have been the next. createAuth now spreads
its config into the engine, so it cannot forget a key that is added later.
caf7ddf: The rest of the critical and high findings in
core/flows: an enumeration oracle, a delete that took three other flows' tokens with it, a signup that produced rows Postgres refuses, and a signup with no rate limit.requestPasswordResetno longer answers, by clock or by error, whether an address is registered.The unknown-address branch was a token gesture — one
sha256— against a write and two reads on the known one. Separable by response time from anywhere. The library already had the correct version of this defence inpasswords.ts, which runs a full hash againstNO_IDENTITY_SENTINELso the unknown path costs what the real one costs; the reset flow gestured at it without achieving it.Both branches now mint a token, hash it, and make the same three store calls in the same order, against the same sentinel. What could not be mirrored is the write —
auth_credentials.identity_idis a foreign key, so there is no row to hang a decoy on — leaving one write against one read on the same table, down from one hash against three round-trips. The residue is pinned as aFINDING:rather than papered over.The louder half was not a timing signal at all: a channel that was not configured threw for an address that exists and returned
{ok:true}for one that does not, saying it in plain language in the response body. The channel check moved above the identity lookup, so a wiring fault is a wiring fault regardless of who asked.One consequence worth stating:
requireMfa()now runs on both branches, so a deployment with no MFA provider getsAUTH_PROVIDER_NOT_REGISTEREDfrom either — the flow has always needed the provider for a known address, and the asymmetry was itself the oracle.requestEmailVerificationno longer voids three other flows' tokens.It ran
deleteByKind(identityId, 'recovery'). Four different flows sharekind: 'recovery'— password-reset tokens, email-verification tokens, account-deletion tokens, and signup-flow state — and they are told apart only by a metadata field, whichdeleteByKindcannot read. So asking for a verification mail silently destroyed an in-flight password reset, a pending deletion confirmation, and an in-progress signup, stranding the user with a flow token that no longer resolved.Now it lists the identity's
recoveryrows and deletes only those whose purpose isemail-verification— the same list-filter-deleterequestAccountDeletionalready used. It still replaces its own stale token, so two requests never leave two live links.One discriminator, written by everything. That fix is only correct if every flow supplies a purpose, and two did not: signup and password-reset wrote
metadata.kindwhilegetCredentialPurpose()— the helper every guard and every delete reads — readsmetadata.purpose, and so returnedundefinedfor both. All four now writepurpose, and the two flows that reached intometadata.kindby hand read the helper instead.beginSignUpbuilds a profile the library's own Postgres adapter accepts.Identities.ProfileMetadataBaserequiresusername. duck-auth's pg schema enforces it withchk_auth_identities_profile_shapeplus a unique index onlower(profile->>'username'). AndbeginSignUpbuilt{ ...initialProfile, email }— no username — then cast past its own type withas unknown as Profile. SinceinitialProfileis optional,beginSignUp({ email }), the documented happy path, produced an INSERT the library's own adapter rejects. Nothing caught it because the sqlite conformance DDL states in its own comment that it omits CHECK constraints, and memory and Redis have no schema at all.usernameis now derived from the address when the caller supplies none, and the double cast is gone. From the whole address rather than its local part:usernamecarries a unique index too, so derivingsamwould refuse the nextsam@b.combecausesam@a.comsigned up first — a collision on a handle neither user chose.beginSignUpconsumes the rate limiter. It was the only flow in the unit that never did; password-reset, email-verification and account-deletion all do. One unauthenticated request equalled one permanent identity row, unbounded. Keyed per canonical address, so hammering one victim's address is what gets capped.Breaking:
beginSignUpthrowsAUTH_RATE_LIMITEDwhen the limiter trips. A host with a tight limiter and a signup retry loop will start seeing it.beginSignUpwrites ausernameinto the profile wheninitialProfileomits one. Code asserting the profile has exactly the keys it passed will see one more.Credential rows for password-reset and signup-flow state carry
metadata.purposeinstead ofmetadata.kind. There is no fallback reader: a token minted by the previous version resolves to no purpose and is refused as invalid. Both are 30-minute-TTL rows, so the exposure is one deployment window, and the failure direction is closed.requestPasswordResetthrowsAUTH_MISCONFIGUREDfor a missing channel even when the address does not exist, andAUTH_PROVIDER_NOT_REGISTEREDwhen the MFA provider is absent, on both branches.
Still open — F21's other half. The rate limit caps how fast identities appear; it does not stop one appearing for an address nobody proved they own, so account pre-emption and the duplicate-address refusal remain. The recommended fix — hold signup state in the credentials store and create the identity at completeSignUp — cannot be built, because fk_auth_credentials_identity requires the row that fix defers, and Provider.Context carries no other store that could hold it. That makes it a schema decision rather than a flow one. Both halves are pinned as FINDING: tests.
The fifteen medium and low findings left open in
core/flows— and one critical that closing them turned up.The MFA gate on
completePasswordResetnever checked whose session it was handed.if (await hasTotp(row.identityId)) { const s = await sessions.getBySid(input.currentSid); if (!s || s.aal < 2 || !s.fresh) throw new AuthError("AUTH_RECOVERY_REQUIRES_MFA"); }Nothing bound
s.identityIdtorow.identityId. An attacker holding a victim's reset token — which is the exact situation the MFA requirement exists for — passed their own fresh AAL 2 session and the gate opened. Any account with a second factor was resettable by anyone with a second factor of their own, on any account.The session is now resolved once and only counts when it belongs to the identity the token names. This was not in the audit; it sat three lines from a finding that was, and was missed because a step-up check reads as being about the caller. Here the caller and the subject are different people by construction.
releaseImpersonationlogged the operator out instead of ending the impersonation.It revoked the impersonation session and called
transport.revoke(), which clears the bearer outright. The real session thatimpersonate-startdeliberately keeps alive is no help —impersonateoverwrote the cookie with the impersonation sid, so its plaintext is gone from the client and nothing can present it again.It now routes through the
impersonate-releaserotation purpose, which had sat unused in the matrix since the matrix was written with exactly the semantics needed: mint, then delete the sid that was presented. The operator gets a session of their own back.Breaking: the return type is now
{ session, sid, intents }and carries anissueintent rather than arevoke.sessionisnullandsidis''only when the operator's own identity is gone, in which case the bearer is cleared as before.Impersonation sessions no longer claim the operator's authentication.
aalandfactorsdescribe what a session's subject did to prove they are there, and the subject of an impersonation session is the target, who did nothing. Copying the admin's verbatim wrote the admin's TOTP, at the admin'scompletedAt, onto the target's session — so any policy asking "has this user recently passed a second factor" got the wrong person's answer for as long as the impersonation lasted.Both the impersonation session and the session
releaseImpersonationhands back start ataal: 1with no factors. The operator's assurance is not discarded, it is spent: it is the input toauthorize(realSession, targetIdentityId), which is where an operator-AAL requirement belongs. Behavioural — hosts that readaaloff an impersonation session will now see 1, and an operator returning to privileged work steps up again.linkProvidernow requires anauthorizecallback.providerSubarrives as a plain string. Nothing about it is verifiable from inside duck-auth. Wired to a completed OAuth callback it is exactly right; wired to a route that trusts a request body, an attacker links their own provider account to a victim's identity and signs in as them from then on. The signature left that difference unstated, so both readings looked like correct usage.await auth.flows.linkProvider({ identityId, providerId: "authGoogle", providerSub: claims.sub, authorize: async ({ identity, providerId, providerSub }) => /* your check */ true, });Breaking. A missing callback is
AUTH_MISCONFIGURED; a refusal isAUTH_PROVIDER_FAILEDand writes nothing. Same shapeimpersonateandcancelAccountDeletionalready use for the same class of problem, and the same warning applies: neverasync () => true.Unlinking a provider is audited, and can no longer race itself into a lockout.
identity.linkedexisted;identity.unlinkeddid not. Removing an authentication factor — the write an account takeover performs to close the real owner's way back in — left no trace at all. The new event carriesallowedLockoutso an override is visible in the trail, and is registered for webhooks and audit envelopes.The lockout guard was also a read followed by an unversioned write. Two concurrent unlinks of different providers each saw the other's link still present, each concluded a factor would survive, and both landed — an identity with no way back in, produced by a guard whose entire job was to prevent that. The question is now re-asked against the post-write row, and the loser puts its link back with the original
addedAt. Both racers rolling back is possible and is the safe direction.completePasswordResetends sessions before it writes the password, and answers with intents.The old order left a window with the new password live and the old sessions still open: if the revoke threw, whoever held a session the reset was meant to end kept it, and the account holder had no way to tell. Failing before the password write is the harmless direction — nothing has changed, and the user asks for another link.
A caller who is signed in now rotates through
rotateOrCreate({ purpose: 'credential-change' })rather than merely being swept, which puts the transition back on the single rotation pathsessions.tspromises every privilege change takes. The ordinary email-link reset still sweeps:credential-changemints as well as revokes, and minting there would turn a reset link into a way to sign in.Breaking: the return type is now
{ ok: true; intents: Provider.Intent[] }.intentsis empty on the email-link path and carries a replacement bearer when the caller was signed in.A failed MFA gate is also bounded now. Consuming the token outright would break the documented flow — being refused, stepping up, and calling again with the same token is how it is meant to work — so the gate is rate limited per token hash instead, and when the bucket is spent so is the token.
The password-reset row no longer duplicates the address.
metadatacarried{ purpose, email }. Nothing read the address back — the flow resolves the identity fromrow.identityId— and it was a second copy of PII in a tableidentities.erasehas no reason to sweep, so an erased account left its email sitting there until the row's TTL expired.Signup state is bounded, atomic, and written through the facet.
profileMaxBytesnow applies toflow.dataatbeginSignUpandadvanceSignUp, not only when it finally reaches an identity row. Flow state lives in credential metadata, which has no size limit of its own, for up to 24 hours and is re-read on every stage — a cap the profile only meets on its last hop is not a cap.advanceSignUpwasrotate→revoke→upsert: three writes, no transaction, and the middle one destroys the token. A failure after the revoke stranded the user mid-signup, and the recovery depended onupsertre-accepting a secret hash that had just been revoked. It is nowrotate(with the row's own secret, so the compare-and-set survives without invalidating the token) thenpatchMetadata, one atomic merge.completeSignUpwrites throughidentities.updateProfileinstead of the raw store, which also deleted a hand-rolled merge and itsas Profile.The rotation is
purpose: 'sign-up', a new entry in the matrix.previousSidis optional and most signups have no prior session, so calling every one of them aguest-promotionmade the rotation log describe a transition that never happened.
Email verification.
IdentitiesImpl.markEmailVerifiedreads its own expected version and retries once onAUTH_STALE_WRITE. The flow consumes the single-use token before this write, so a concurrent profile update used to surface a raw stale-write error with the token already spent and no second attempt for the user to make.- The rate limiter moved below the two early returns that send nothing. An unknown id and an already-verified address used to spend a real user's resend budget on a request that could never produce a message.
Also: IdentitiesImpl.assertProfileWithinCap is public (and takes unknown) so callers staging profile data elsewhere can hold it to the same limit; provider-link.flow.ts builds one Provider.Context per call instead of two or three.
Every fix is pinned in src/core/flows/__tests__/flows-c6-open-findings.test.ts, each written to fail against the pre-fix code, and four of them again on real Postgres in flows.e2e.test.ts.
b06c50b: Expired sessions are no longer accepted at any privileged gate.
SessionsImpl.getBySidhashed the sid and returned whatever the store had — no expiry check at all.resolveBySid, in the same file, checks both deadlines and deletes the row. Two reads that look alike, one of them unsafe, and four call sites depended on the unsafe one:completeStepUp— an expired sid completed a step-up, androtateOrCreateminted a brand new live session from the dead one. Presenting an expired sid did not merely pass the gate; it resurrected the session.impersonate— an expired admin session could start an impersonation, and the app's ownauthorize(real, target)callback was handed that dead session to judge.completePasswordReset— a stale session withaal >= 2satisfied the MFA gate, so a valid reset token plus a long-dead session changed the password with no live MFA behind it.The Redis store serving rows past
expiresAtwas correct layering — expiry is the facet's policy, not the store's — but this is what made it reachable.
getBySid now refuses a session past either deadline and deletes the row, the same side effect resolveBySid has. Folded into the read rather than patched at each call site, so the fifth caller added later cannot reintroduce it.
fresh is computed, not read. It was a persisted boolean that only touch() ever refreshed, so a session written fresh: true and never touched still claimed freshness weeks later — and the password-reset gate reads exactly that field. getBySid now derives it from rotatedAt and freshnessMs.
Also: the date narrowing copy-pasted across getBySid, touch and resolveBySid is one isSessionExpired helper now, which removed the four as number casts those copies carried. It fails closed on a non-finite or missing deadline, because NaN < now is false — a lenient read keeps a should-be-dead session alive forever.
Breaking:
getBySidreturnsnullfor an expired session instead of the row, and deletes it. Code relying on it to read expired rows must go toSessions.Store.getByHashdirectly.- The
freshfield on a session returned bygetBySidis now computed fromrotatedAt. A session that was storedfresh: truebut rotated longer ago thanfreshnessMsnow reportsfalse— which is what the gates guarding password changes and step-up were always meant to see. - New exports
isSessionExpiredandisSessionFreshfrom~/core/sessions.
resolveBySid recomputes it too. It takes an optional freshnessMs on its options bag, defaulting to DEFAULT_SESSION_CONFIG.freshnessMs, and AuthEngine.resolveSession passes its own — so the cookie path now means the same thing by session.fresh that the JWT path has always meant, which recomputes from rotatedAt on every verify.
And fresh turned out to be two claims in one column. Recomputing purely from the clock broke a step-up case, and the failure was correct: rotateOrCreate({ purpose: 'step-up' }) demotes the session being stepped up from by writing fresh: false onto a row whose rotatedAt is seconds old. The clock half decays; the stored half revokes. isSessionFresh is now the AND of both — a stored false is sticky, so freshness expires with time and can be withdrawn early, but storage can never grant it.
strict()demanded alockouthandler for an event nothing in the library emitted.engine.strict.tsrefuses to boot production unless something is subscribed to
lockout. Nothing ever published one. Eight limiter guards — password sign-in,
magic-link, api-key, signup, password reset (request and MFA gate), email
verification, account deletion — each computed a Retry-After, threw
AUTH_RATE_LIMITED, and told nobody. telemetry/otel counts lockout and
webhooks forwards it; both had been waiting on a publisher since they were
written. Operators were made to wire a handler for an event that could not arrive,
which is worse than no check at all: it reads as coverage.
All eight now route through one helper, refuseRateLimited, which emits and then
throws. A refusal that knows whose account is under pressure names it; one that does
not stays silent, because lockout carries { identityId, until } and an emit with
no subject is a page nobody can act on.
Emits: password sign-in, requestEmailVerification, requestAccountDeletion,
and the completePasswordReset MFA gate.
Stays silent, deliberately: api-key (the only route from a token-hash bucket to
a subject is the verification the guard exists to shed), magic-link and
requestPasswordReset (resolving the address means running host code on every
refused request to an unauthenticated endpoint, and a spent mail bucket blocks a
delivery rather than an authentication), and beginSignUp (no account exists behind
the address yet).
Two ordering changes came with it:
authPassword.completenow looks the identity up before consuming the limiter. This is the bucketlockoutexists for, and it is the one site that pays for its subject — a refused request costs one indexed read the limiter used to shed, still a fraction of the argon2 verification the guard is actually there to stop. The happy path is unchanged; it made the same call one line later.requestAccountDeletionconsumes the limiter below the identity lookup, the same reorderrequestEmailVerificationalready had. Above it, an id with no row behind it spent a bucket and would now have paged an operator about an account that does not exist.
Two bugs fell out of collapsing eight copies into one:
Retry-After: 0. Seven of the eight usedMath.max(0, …), which tells a client to retry immediately for the last fraction of a second of a window — from the guard whose entire purpose is to say do not. The floor is1everywhere now, the valueapi-keyalone already used.A broken
resetAtdefeated the limit. Every copy calledlimited.resetAt.getTime()unguarded, so a limiter adapter returning a number raised aTypeErrorand the caller saw a 500 instead of a 429 — the limit did not apply. An invalidDateproducedretryAfter: NaN. The helper reads it defensively and still refuses.authRefreshoauthTokenrequires theidentitiesprobe instead of asking nicely for it.It was optional, documented as "should be supplied". Nothing in the library calls this
function — refresh-token rotation is invoked by the host directly — so there was no wiring to supply it, and the only guidance a caller got was a signature saying the safe argument was opt-in. Omitted, a refresh mints new tokens for an identity that has since been deleted and hands its id back, because nothing on the credential-first path looks at the identity.
apiKeyProvider passes stores.identities unconditionally for the identical check. This
makes the same guarantee unskippable. A client-credentials grant, which has no
row.identityId, still skips the lookup.
beginPasskeyRegistrationtells the authenticator which keys the identity already has.excludeCredentials— WebAuthn's mechanism for "this account is already enrolled on this
authenticator, do not mint a second key" — was declared on Passkey.RegistrationOptionsInput
and on Passkey.RegistrationOptions from the start, and never once populated. Every
registration ceremony went out with the field absent, so a user who ran registration twice on
the same device got two credentials for the same account with nothing to tell them apart:
same name, same device, and no way to know which one to prune.
Breaking: beginPasskeyRegistration's second argument now requires credentialStore and
tenant, matching completePasskeyRegistration, which already took both. That is what the
ceremony needs to read the identity's live passkeys.
Revoked rows are deliberately left out of the exclusion list: a revoked passkey is one the user asked to be rid of, and re-enrolling that authenticator is the documented way back in. Transports are sent when the authenticator reported them and omitted rather than guessed when it did not.
Found by sweeping for option keys that a type declares and no implementation ever reads — the
same sweep that turned up onFederationConflict.
11394fc: The public surface now names what it returns, and a post-commit drain no longer reports a committed write as a failure.
pending.flush()never rejects. It resolves with{ published, failed: Error[] }.flushruns after the caller's transaction has committed and empties the buffer whether or not a listener threw, so a rejection asked the caller to handle a failure with nothing left to retry — and the natural handling, letting it propagate, answered a committed write with a 500 whose one promise, that the write did not happen, was false. The write happened; some announcement of it did not. Those are different facts and callers need to tell them apart, log the second, and still answer 200. A thrown non-Errorarrives as anErrorwith the original oncause, sofailedis always loggable.Callers who want the old behaviour can write
if (failed.length) throw new AggregateError(failed). The reverse was not available, which is why this changed.identities.restore()returnsMe | null. One rule now covers all three lifecycle writes:nullmeans the id matched nothing — the same outcomesoftDeleteanderasealready reported that way — and a throw means a row was matched and a named rule refused it (AUTH_GRACE_EXPIRED,AUTH_EMAIL_TAKEN).restorewas typed as though it could not miss, which told you which outcome the author remembered rather than which outcomes exist. It threwAUTH_UNAUTHENTICATEDfor an absent id; that path is nownull.flows.cancelAccountDeletionis unchanged and still throwsAUTH_UNAUTHENTICATED: at a flow boundary there is no account whose deletion could be cancelled, which is an error rather than data.The root barrel re-exports the domain types.
@gentleduck/authexported the engine and none of the types the engine returns, so naming the type of any return value meant a second import from@gentleduck/auth/core.Identities,Sessions,Credential,Org,Events,Batch,Pending,Flows,Engine,Bound,Provider,Transport,TenantContext,Envelope,Anomaly,Compliance,Hijack,M2m,Operations,KmsandDataAtRestare now reachable from the root. These are types only — the runtime surface is unchanged and still deliberately narrow.Identitiesis exported under its own name, andIdentityis gone.@gentleduck/auth/coreexported the namespace asIdentity, so it read asIdentitiesin every declaration, doc comment, internal signature and error message, and asIdentityat the import site. There is now one name. Replaceimport type { Identity } from '@gentleduck/auth/core'withIdentities; the members are unchanged, so the rest of each reference stays as it is.CHANGELOG.mdships in the package. The README links it andfilesdid not include it, so on npm the link 404'd and the only way to see what a release contained was to diff two tarballs.@gentleduck/iamhad the same gap and is fixed too.Backup codes no longer wipe the user's password reset, and no other recovery token is a second factor.
Credential.kind = 'recovery'names six unrelated things — a password-reset token, a
verification mail, a pending account deletion, an in-flight signup, an MFA backup code
and a remembered device — and kind distinguishes none of them from each other.
metadata.purpose is the real discriminator. The flows had already learned this; both
backup-code implementations had not.
Deletes. BackupCodesFacet.generate, BackupCodesFacet.revokeAll and
MfaImpl.regenerateBackupCodes each called deleteByKind(identityId, 'recovery'),
which reads as "replace the codes I own" and means "delete all six". Regenerating backup
codes therefore voided whatever the identity was holding: the reset link sitting in their
inbox, their verification mail, their pending deletion request, their half-finished
signup, and every device they had asked the site to remember. All three now delete by
purpose.
Reads. BackupCodesFacet.verify and MfaImpl.verifyBackupCode hashed the submitted
code and compared it against every recovery row, so the other five token families were
candidate second factors. Not practically reachable — both verifiers case-fold before
hashing (toUpperCase and toLowerCase respectively) while the other five store the
hash of a raw base64url token, so a collision needs a token with no letter of the wrong
case — but the guard was case-folding luck, not a check. Both now filter on purpose.
BackupCodesFacet.remaining counted the same six families, so "you have N backup codes
left" counted reset tokens.
Breaking. Backup-code rows written before this release carry no
metadata.purpose and will not verify. Have affected users regenerate their codes.
MfaImpl.regenerateBackupCodes and BackupCodesFacet.generate both mint a fresh,
correctly stamped set.
New: RECOVERY_PURPOSES (the six names, in one place) and
deleteCredentialsByPurpose(store, identityId, kind, purpose, ctx), which is the
list-and-delete requestEmailVerification and requestAccountDeletion were each doing
inline. Both now call it.
The two backup-code implementations remain independent and mutually incompatible —
MfaImpl hashes code.toLowerCase(), BackupCodesFacet hashes an upper-cased,
hyphenated form — so codes minted by one still cannot be verified by the other. Sharing
a purpose does not make them share a format; that is a separate change.
01eda74: The Redis session store: a parser that threw instead of failing closed, a create that could leave a session no revocation could reach, and a
gcthat never took the lease its contract promised.parseStoredSessionnever throws now — and stops losing factors quietly.Its whole contract is that a corrupt or tampered row reads as "no session" rather than taking a request down. It did not hold.
Array.isArraynarrowedfactorstoany[], so the filter read.methodoff whatever was in the array; a singlenullelement threw, outside thetry/catchthat guards onlyJSON.parse.getByHashis on the path of every authed request for that session, so one bad blob was a permanent 500 — andlistByIdentity, which parses in a loop, went down with it.Every other malformed shape was the quieter half and the more dangerous one: it was dropped. A row claiming
aal: 2came back carrying no factors at all, and step-up logic reads that list as authoritative.A structurally broken entry —
null, a primitive, nomethod— now rejects the whole row. That is corruption, and a session we cannot read is not a session we should serve.An unknown but well-formed method is still skipped, not rejected: that is what a newer writer adding a factor method produces, and an older reader must not choke on it.
The 16-element
factorscap thatsessions.createandparseJwtPayloadboth apply is now enforced here too. This reader was the one door in that had no cap.A malformed
actingAsenvelope is refused rather than degraded tonull. Degrading it lost both halves of what the envelope is for — the audit trail naming the real actor, and the expiry bounding the impersonation window — and left the row reading as an ordinary session belonging to the person being impersonated.
create writes the index before the record. The two writes are separate round-trips. A failure between them used to leave a session that authenticates fine but sits in no identity index: listByIdentity cannot see it and deleteAllForIdentity cannot delete it, so it survives the password change or ban that was supposed to end it. Reversing the order alone is not enough and would have made things worse — a concurrent listByIdentity pruned the entry inside the gap, and a record no index names is unreachable forever. So listByIdentity no longer prunes: a missing record may be a create still in flight, and only that create knows. A failed record write compensates its index entry, but only when sadd reports it actually added one — otherwise a duplicate create would unindex whichever session won the race.
gc is driven by an expiry index instead of walking every session. The sweep used to SCAN every {prefix}:idx:identity:* key, read every member's record, parse it, and compare two dates — O(every session that exists) per cycle, on a schedule, with a get and a JSON.parse for each one. It also could not sweep guest sessions at all: they carry no identityId, sit in no index, and so were reachable only by their key TTL, which tracks absoluteExpiresAt and therefore never enforced the sliding expiresAt on them.
There is now a single deployment-wide sorted set, {prefix}:exp, whose members are sessionId:identityId scored by whichever deadline comes first. gc pages it with ZRANGEBYSCORE -inf <now> LIMIT, so a cycle costs one range query plus the rows that are genuinely due — nothing proportional to the sessions that are still live. Because the member carries the owning identity, a row is deleted and dropped from its identity index without its body ever being read, and a guest session is swept like any other.
The write paths keep the index in step: create adds the member after the record lands (an nx collision must not re-score the live session already under that id, and a create that cannot be scheduled unwinds itself), update re-scores before writing so a renewal is never swept on its old deadline and moves the member when the session changes identity, and delete / deleteAllForIdentity retire members directly rather than leaving them to come due.
This also closes the race the index walk could only narrow. That sweep met index entries naming records that had not landed yet and had to re-read to guess whether they were orphans; a session earns an expiry member only once its record exists, so a create in flight is invisible to gc by construction. The confirming second read is gone with it. create now refuses a session id containing :, since that is where the member splits.
gc takes a distributed lease. Sessions.Store.gc has been documented as "acquires distributed lease before running" since 5.x, and no implementation acquired anything — every instance in a fleet swept concurrently. Worse than the waste, an operator reading that line would reasonably conclude they did not need their own locking. RedisSessionImpl now takes {prefix}:gc:lease with SET NX and returns { deleted: 0 } when it loses. The lease is left to expire rather than released in a finally: a sweep that outruns the window has already lost it, and deleting it then frees a lease the run no longer holds. New gcLeaseSec config, default 300s. The SQL store's gc is unaudited and was left alone; the contract's docstring now says what it actually guarantees instead of over-promising.
_ttlFor fails closed. It assumed anything that was not a Date was a number, so an absoluteExpiresAt that arrived as an ISO string — what a JSON round-trip produces — made every step downstream NaN, and { ex: NaN } reached the client. Some clients write that as a key with no expiry at all: an immortal session. It now parses through the same parseStoredDate the read path uses, so a serialised date yields the session's real TTL rather than the 30-day ceiling, and only a genuinely unparseable value falls back to the cap.
listByIdentity reads concurrently. It awaited one get per session id in a loop. It backs the active-devices view and runs inside revokeAllForIdentity, so those were N sequential round-trips on a request path. Now Promise.all over the same get — no new client methods, since widening RedisLike.Client would force every adapter to implement more.
Also fixed here:
- The parser's
kindandaalgates were written withascasts; they areisSessionKind/isAalpredicates now, along withisRecordandisFactorMethodfor the new checks. - A session-security e2e case read the clock four separate times to build one row, so
absoluteExpiresAtandexpiresAtcould straddle a millisecond tick and invert — trippingchk_auth_sessions_absolute_expires_after_expiresand reporting a working constraint as a broken write. Both such cases now take a single reading. - The identity compliance suite gave
restoreManya one-second grace window and then spent it on a create-and-erase round-trip against the real database. On a loaded MySQL container the window closed first, and correct behaviour was reported as a failure. The round-trip now happens before the clock starts, on the minute-long window the rest of the suite uses.
Breaking:
Stored session rows that are structurally malformed — a broken
factorsentry, more than 16 factors, a partialactingAs— now resolve asnullinstead of resolving with the bad part silently removed. Sessions affected by this were already carrying data the library could not vouch for; they will need to sign in again.Sessions.Store.gcimplementations that can run on more than one instance are now required to serialise themselves. Nothing enforced this before because nothing did it.RedisLike.Clientgainszadd,zremandzrangebyscore. A custom client implementing this interface by hand will not compile until it has them;FakeRedisand the bundled valkey/ioredis adapter already do.@upstash/redisand node-redis expose all three under those names.RedisSessionImplclaims a new key,{prefix}:exp. A deployment that namespaces by prefix is unaffected; one sharing a database with other data should confirm that key is free.Sessions created before this release have no expiry member, so
gcwill not sweep them — they fall back to their key TTL, which is what bounded them before this index existed, and their identity-index entries are cleared when that index key's own TTL lapses. They are otherwise fully usable, and anyupdateschedules them. To sweep them promptly instead, backfill from the existing records once after deploying, or accept that they age out.RedisSessionImpl.createnow rejects asession.idcontaining:withAUTH_MISCONFIGURED. The sha-256 hashes the library generates never contain one; a deployment substituting its own id scheme must not use that character.Sessions can be read and revoked per tenant. They were the only tenant-scoped store with no tenant parameter.
Every store meant to be tenant-scoped takes a
TenantContexton every method —
Credential.Store on all ten, Org.Store on all six. Identities.Store takes none
and is deliberately global; the conformance suite says "identities are global" in as
many words. Sessions sat between the two: a tenantId column that no method could
select on, against a TenantContext docstring promising "stores receive it on every
call".
Because identities are global, one person is routinely a user of tenant A and of
tenant B with every session hanging off one id. So listByIdentity(identityId) handed
tenant A the IP, user-agent and existence of that person's tenant B sessions, and
deleteAllForIdentity(identityId) — "sign out everywhere" — logged them out of tenant
B when tenant A revoked. resolveSession(req, { expectedTenantId }) does not cover it:
it compares after the read and the option is optional, and core/tenant/tenant.ts says
outright that "withTenant is a default, not a fence".
Sessions.Store.listByIdentity and deleteAllForIdentity now take an optional
ctx: TenantContext, as do SessionsImpl.listForIdentity and
SessionsImpl.revokeAllForIdentity. Omitted, or carrying no tenantId, it means every
tenant, so every existing caller keeps its current answer. Named, it matches exactly —
which puts a global (tenantId: null) session outside a named tenant's scope, the same
rule Credential.Store already follows.
Implemented in all three stores. SqlBridge.Session.listByIdentity and
deleteAllForIdentity take tenantId: string | undefined, mirroring the credential
bridge — a breaking change for anyone who implements the SQL bridge directly; the
shipped pg, sqlite and mysql adapters are updated. Two cases were added to
runSessionStoreCompliance, so all five shipped stores are held to the rule and the
pg, mysql, redis and valkey runs prove it against real servers.
Still global, deliberately: the credential-change sweep in rotateOrCreate (a
password belongs to the global identity, so changing it must end every session it could
have opened), revokeAllForIdentities (scoping the loop fallback but not the optional
set-based store form would make one call behave differently per adapter), and
identities.erase.
Also fixed: FakeRedis.del ignored sets and sorted sets. It deleted from its string
map only, so dropping a set or zset key was a silent no-op — and the session store's
index and expiry keys are exactly those types. runSessionStoreCompliance runs the whole
store contract against FakeRedis in-process precisely to catch divergences, so any case
asserting "the index key is gone" had been passing against a fake that never removed it.
The Redis scoped delete srems the ids it removed rather than deling the index key:
dropping the key would leave the sessions it spared alive and unreachable by every later
read and sweep — signed in, with nothing able to sign them out.
beginSignUpno longer lets anyone permanently claim someone else's address.It writes an
auth_identitiesrow for an address the caller has not proved they own.
The unique index on email then means the real owner's signup collides with the squat and there is nothing they can do about it, ever — and the duplicate-email refusal answered "is this an account?" to anyone who asked.
Deferring the identity until the address is verified — the obvious fix — is not
buildable: fk_auth_credentials_identity is a NOT NULL foreign key to the very row that
plan defers, so the credential holding the flow token has nowhere to live, and
Provider.Context carries no other store that could host it.
So the squat is disarmed rather than prevented. beginSignUp reclaims an existing row
when, and only when, it is in exactly the state beginSignUp itself leaves behind:
emailVerified false, no provider links, and no credential that is not a signup-flow
token or already revoked. The previous flow's tokens are revoked, the profile becomes
the new signup's, and the caller gets the row. An attacker parking on victim@corp.com
no longer blocks its owner — the owner takes the parking spot.
The predicate is the safety argument, not a detail. completeSignUp mints a session
for the reclaimed identity, so a rule one notch looser would be account takeover rather
than squat reclaim: an unverified account with a password set is somebody's, and so is
one with a Google link or a TOTP secret. MFA backup codes are kind: 'recovery' under
purpose: 'mfa-backup-code', so they fail the signup-flow test and read as
established, which is right. The credential check is
deliberately unscoped — identities are global while credentials are not, so a
tenant-scoped read would miss a password in another tenant and call an established
account abandoned.
completeSignUp now records the verification the flow claimed. Nothing in this flow
ever wrote emailVerified, so an account created through the documented happy path
stayed unverified for good. Harmless before; under the reclaim rule it would have left
finished accounts reclaimable forever. When the email-verified stage is among the
completed ones, the column is written, and the session is issued from the settled row.
Still open, deliberately. The identity row is still written on an unauthenticated
request — bounded by the signup:begin:<address> limiter, and no longer a claim. And an
established account still answers AUTH_EMAIL_TAKEN, because the address is unique
and a second account for it cannot exist. Closing that needs what requestPasswordReset
has — a channel to answer through, so the owner is mailed and every caller gets the same
reply — and beginSignUp takes no channel.
completeStepUpreads the second factor in the session's tenant, not in one the caller names.It took an optional
tenantIdthat scoped the MFA credential read, while the session being
stepped up carried a tenant of its own — and nothing bound the two. The default when
tenantId was omitted made it worse: {}, an unscoped read. Credentials are
tenant-scoped and identities are not, so a TOTP secret or backup code enrolled in tenant B
satisfied a step-up for a session in tenant A — a tenant that had never seen a factor for
that identity, and whose hasTotp therefore never asked for one. Any policy gating admin
work on aal >= 2 accepted a factor its tenant had never issued.
Breaking: completeStepUp no longer accepts tenantId. The factor is looked up in
session.tenantId (unscoped for a global session, matching every other credential read),
so there is no second input left to disagree with the session.
Found by sweeping for the shape of F28 — a gate that resolves its scope or its subject from
one input and acts on another, with nothing binding them. It was the only other live
instance; impersonate, signIn, linkProvider and releaseImpersonation each bind
their two inputs already.
onFederationConflictis reachable from the six shipped OAuth providers.The policy that decides what happens when an OAuth profile's email already belongs to a
local identity with no link to that provider — 'reject', 'link-if-verified', or a
caller-supplied (ctx) => 'link' | 'reject' hook — was declared on OAuth.Options, read by
OProviderImpl, and documented in full. It was also unreachable: OptionsBase, the type
every shipped provider factory takes, never declared the key, and oProvider is not
exported from any package entrypoint. So google(), github(), linkedin(),
microsoft(), discord() and apple() were all hard-wired to 'reject' with no way for a
consumer to choose otherwise.
'reject' is the safe direction, which is why nothing broke and nobody noticed — it made
the feature dead rather than dangerous. OptionsBase now carries the key and all six
factories forward it, so "merge after out-of-band confirmation" and "link when the IdP says
the address is verified" are configurable rather than documentation for a branch no caller
could reach. The default is unchanged.
The policy branch also had no test of any kind; it now has five, driven through google()
against a stubbed IdP.
Patch Changes
The client hands back real
Dates, andrevoke()says a key is revoked.Sessions.MeandIdentities.MedeclareDatefor every deadline they carry. The vanilla
client's getSession() did a bare JSON.parse of the response and handed the result back
under those types, so every one of those fields was an ISO string wearing a Date annotation.
The loud half was session.expiresAt.getTime is not a function. The quiet half was worse:
session.expiresAt > new Date() compares a string against a Date, is always false, and a
live session reads as expired with nothing thrown. All four framework clients (react, vue,
svelte, solid) wrap the vanilla one, so all four had it.
Revival happens once, in getSession, over a named list of fields — not by sniffing values
for date-shaped strings, which would rewrite identity.profile, whose contents belong to the
host app. A string that will not parse is left exactly as it arrived rather than promoted to
an Invalid Date, which passes instanceof Date and compares false against everything.
ApiKeysFacet.revoke() documents that it answers with "the key as it stands revoked". The
projection behind it copied createdAt, lastUsedAt and expiresAt and dropped revokedAt,
so ApiKeys.ApiKey.revokedAt was declared by the package and never once populated — a UI
reading the answer back could not tell the revoked key from a live one.
The three store-compliance suites now assert, on every read path, that each field is the type
its row declares — including that a Date is a Date and not an Invalid one. That runs
against every shipped backend: memory, drizzle sqlite/postgres/mysql, redis, valkey.
Updated dependencies [70a9e4d]
Updated dependencies [ff6f112]
Updated dependencies [3de4298]
Updated dependencies [8c131a4]
Updated dependencies [3de4298]
- @gentleduck/iam@5.8.1
5.4.1
Patch Changes
11394fc: Adapter audit: batch outcomes name the rule that refused a row, and a soft-deleted account no longer holds a provider login hostage.
Batch.FailureReasongainedgrace-expiredandemail-taken.not-foundwas reported for every row arestoreManydid not apply, including rows that were found and then refused by the grace window or by an address a live identity had taken since. A caller toldnot-foundhas no way to learn the id is still there and still restorable once the clash is resolved. Both batch paths were affected — the set-based SQL form and the loop fallback used by the memory and Redis stores. Consumers matching exhaustively onFailureReasonwill need the two new arms.A soft-deleted row no longer holds its provider sub. The cross-identity uniqueness guard counted hidden rows, but
findByProviderSubignores them, so a sub could be unreadable and unclaimable at the same time — a deleted account keeping someone's Google login forever. All four adapters now count live rows only, matching what the read side already did.restoreManyReturning(SQL bridge) returns{ candidates, restored }. CustomSqlBridgeimplementations need updating; the dialects shipped with the package already do. The candidate rows are what let the store report why a row did not come back instead of guessing.isRestorableis exported from@gentleduck/auth/adapters/sql, so the batch and single-row paths cannot drift about when a grace window has closed.
5.4.0
Minor Changes
The SQL adapters now hand back real
Dates for the timestamps stored inside JSON columns.Identities.ProviderLink.addedAt,Sessions.Me.factors[].completedAtand both dates onSessions.Me.actingAslive insidejsonb/json/textcolumns.JSON.stringifywrites aDateas an ISO string and the driver hands that string straight back, so on pg, mysql and sqlite all three were typedDatebut held astringat runtime. The memory and Redis stores return realDates, so nothing in the library noticed — it surfaced in caller code asaddedAt.getTime is not a function, and asactingAs.expiresAt < Date.now()comparing a string to a number, which is alwaysfalse: an impersonation window that never looked expired.createSqlStoresrevives them at the boundary where bridge rows become library rows, so every dialect is covered at once.An unreadable
addedAtkeeps the link and falls back to the row'screatedAt. The date is informational, and dropping the entry would silently remove a way into the account.An unreadable
factors[].completedAtfalls back the same way.actingAsgets no fallback: a window whose start or end cannot be read is dropped, matching what the Redis session store already did.
Two new store-compliance tests hold memory, sqlite, pg and mysql to this, rather than only the adapter that happened to be exercised.
Anyone reading these fields on a SQL adapter was reading a string; on 5.3.6 and earlier the workaround was new Date(link.addedAt) at every call site, and those calls keep working unchanged.
5.3.6
Patch Changes
80c809a: Deleting an identity now ends every way into it, and a restore no longer resurrects claims the account can no longer prove.
flows.signInre-reads the identity behind astartSessionintent, so every sign-in provider was already covered. The credential-first surfaces were not: they resolve a credential row and hand backrow.identityIdwithout ever looking at the identity.apiKeys.verify()refuses a key whose identity has been soft-deleted or erased, reportingAUTH_APIKEY_INVALID— the same code as an unknown key, so whether an id still exists is not something an unauthenticated caller can probe.M2MImpl.exchange()is covered by the same check; it previously minted a live bearer token for a deleted account.apiKeyProvider()wires the identity store in automatically, and the constructor argument is optional so a directnew ApiKeysFacet(...)keeps working.authRefreshoauthTokenaccepts an optionalidentitiesprobe and refuses to refresh for a deleted identity. The check runs before the CAS claim and before the provider exchange, so a dead refresh costs nothing and leaves the row intact for a later restore.softDeleteclearsemailVerified. The unique indexes are partial ondeletedAtandfindByEmailfilters the same way, so the address is genuinely free while the row is hidden; restoring must not hand back a verified claim to an address the identity may no longer control.restorerefuses once the grace window has closed, reportingAUTH_GRACE_EXPIRED. The SQL bridges previously cleareddeletedAtunconditionally, bringing back accounts whose window had long since closed — including ones already queued for hard purge — while the memory adapter refused them. The dialects now agree with the memory adapter.restorerefuses when the address was claimed while the row was hidden, reportingAUTH_EMAIL_TAKENinstead of a raw unique-index driver error on SQL, or two live rows sharing an email on an adapter with no such index.completePasswordResetrefuses a token whose identity has been deleted, reportingAUTH_RECOVERY_TOKEN_INVALIDso a reset link cannot double as a way to ask whether an account still exists. It previously rotated the token, wrote the new password and emittedrecovery.password.completedfor an account nobody could sign in to.
assertRestorable, assertEmailFree and profileEmail are exported from ~/adapters/sql for bridge authors implementing restore.
45e1ff6: Every mutating call on the public API now answers with what it did.
A write that returns
voidcannot be told apart from a write that matched nothing, and forces a second read for information the statement already had. Across the engine's surface, the mutating calls now return the row, the rows, or the count they touched —null/[]/0when nothing matched.Identities.
softDelete,erase,link,unlinkandmergereturn the row (restorealready did). Where the dialect hasRETURNINGthis is the same round trip; MySQL re-selects by primary key, as it already does forupdateandrestore.eraseanswers with the row as it was immediately before deletion;mergewith the survivor, already carrying the union of both provider lists.Sessions.
revokeandrevokeByHashreturn the session they ended — the row is already read to find it.revokeAllForIdentityreturns the sessions it ended, so "you were signed out of 4 devices" needs no second query; that list was already read to emit one event per session.Credentials. The store's
revokeanddeletereturn the row,deleteByKindthe rows. On top of that,apiKeys.revokeanswers with the key it revoked, andmfa.removeTotp/removeWebauthnMfaanswer{ removed }— the count, not the rows, which carry the shared secret.Orgs.
removeMemberanswers with the membership as it stands left,setRoleswith the membership carrying its new roles — the sanitized set actually stored, not the one passed in.Flows.
completeAccountDeletion,cancelAccountDeletion,completeEmailVerification,linkProviderandunlinkProvidercarry the identity back asidentity, alongside the fields they already returned.completeAccountDeletion'srestorableUntilis now read off thedeletedAtthe store actually wrote rather than a second reading of the clock, so the deadline reported is the onerestoreis measured against.Operations, webhooks, pending, anomaly.
operations.maintenance/readOnlyreturn the resultingState.webhooks.deliverOnereturns oneDeliveryper eligible endpoint —{ endpointId, delivered, attempts, lastError? }.pending.flushreturns{ published }anddiscard{ discarded }.anomaly.unregisterreturns whether it removed anything.Assertions (
apiKeys.requireScopes,operations.assertOperationsForRoute,hijack.applyReaction), registrations (anomaly.register,providers.register) andplugins.disposestayvoid: they throw or they do not, and a return value would be noise.Four silent failures fell out of making the returns honest:
mergewrote nothing and reported success when the survivor or the dup did not exist. On the SQL dialects it was worse than a no-op: the dup's credentials and sessions were re-pointed at an identity that was not there and the dup was then deleted. The memory adapter had always refused this; every dialect now agrees, andIdentitiesFacet.mergechecks the survivor before any of it runs, reportingAUTH_UNAUTHENTICATED.flows.completeAccountDeletionreported{ identityId, restorableUntil }for a valid token whose identity had since been erased, promising a grace window over nothing. It now reportsAUTH_RECOVERY_TOKEN_INVALID.webhooks.deliverOnedropped a permanently failed delivery in silence when no dead-letter sink was configured — no log, no return value, nothing to distinguish it from one that landed. This was pinned as a finding; the per-endpoint outcome closes it.identities.eraseMany's loop fallback reportsnot-foundfor an id that was not there, matching the set-based path, which always did.
Breaking for custom adapter authors. Identities.Store and SqlBridge.Identity type softDelete, erase, link/insertProviderLink, unlink/deleteProviderLink and merge as returning the row or null; Credential.Store and SqlBridge.Credential type revoke and delete the same way and deleteByKind as returning the rows; Org.Store types removeMember and setRoles as returning the membership or null. An adapter that returns void will not typecheck; return what you touched.
5.3.5
Patch Changes
7a1ce88:
AuthEngine.withTransaction(client)binds the whole public surface - reads included - to a transaction you own, and batch forms report per-row outcomes instead of collapsing tovoid.The client is opaque to the library and handed straight back to your adapter, so the engine never learns what driver you use. Nested calls inherit it:
flows.completeAccountDeletion()reachesidentities.softDelete,sessions.revokeAllForIdentityandcredentials.delete, and all three land on your transaction. Reads are bound too, so a read inside the transaction sees its own uncommitted writes.Events do not fire inside a transaction. They buffer in
pendingand publish onflush(), so a rolled-back deletion never appears in the audit trail as a completed one.discard()drops the buffer,peek()inspects it without draining, and a listener that throws does not stop the drain - every buffered event is attempted and the call rejects with anAggregateErrorat the end.Also in this release:
identitiesgainedsoftDeleteMany,restoreMany,eraseMany,updateProfileMany,linkManyandunlinkMany;sessionsgainedrevokeAllForIdentitiesandrevokeByHashes; the credential store gaineddeleteByIdentities. Each collapses to one statement per table where the adapter can express it and loops otherwise, so every adapter supports every batch form.A hard failure - a constraint violation, a driver error - throws, which inside your transaction aborts the whole thing and is what makes a batch atomic with your own work. A soft failure - a lost optimistic-lock race, a row that was not there - is reported per row as
stale-write,not-foundorskippedwithout throwing.Stores and the drizzle pg, mysql and sqlite bridges declare
withClient. A store that cannot join a transaction makeswithTransactionthrowAUTH_MISCONFIGUREDnaming that store, rather than silently leaving those writes outside your transaction.Guards -
limiter,idempotency,hijackandanomaly- are deliberately not reachable on the bound view. An attacker who can force a rollback must not be able to refund the attempts it cost.createSqlStoresbinds optional bridge methods to their bridge instead of destructuring them, so a bridge implemented as a class no longer loses itsthis.
5.3.4
Patch Changes
- 8a13899: Drop
deletedAtfromauthCredentialsandauthSessions, added in 5.3.3. Both tables'delete()/deleteByKind()/deleteAllForIdentity()are hard-delete by explicit design (retaining a revoked credential's secret forever, or a dead session row, is not a feature), so the column would never have been set by anything in this codebase - it was speculative, not wired to a real soft-delete path.authCredentialsalready tracks its own dead-state via the adapter-managedrevokedAt; a second, adapter-inert "this row is gone" column duplicated that job without adding one.authIdentities.deletedAtis unaffected - itssoftDelete(id, gracePeriodMs)is a real, adapter-managed flow, unlike these two.
5.3.3
Patch Changes
fd6e8c2: Round out audit columns on the drizzle schema (pg/mysql/sqlite):
authCredentials/authSessionsgainupdatedByanddeletedAt(they already hadupdatedAtfrom a prior release) - both rows are genuinely mutated in place (rotate/patchMetadata/revoke, sessionupdate()), so tracking who/whether matters there.authEventsdrops thecreatedByit briefly had - it's an append-only audit log, never updated, so the only actor-relevant fact is who triggered the event, which already lives in the event payload itself.
Every read method (
findById,listByIdentity,findByProviderSub,
findByHashedSecret on credentials; findByHash, listByIdentity on sessions) now
filters deletedAt IS NULL, matching authIdentities. Nothing in this adapter sets
deletedAt on these two tables (delete()/deleteByKind()/deleteAllForIdentity()
still hard-delete, on purpose - turning them into soft-deletes would mean revoked
credential secrets are retained forever, which is a regression, not a feature); the
column is honored if something outside the adapter sets it.
30a595d: Fix the vanilla client never sending the CSRF header, so every cookie-authenticated write (
signOutincluded) failed the server'sverifyCsrfcheck.createAuthClientnow reads the CSRF cookie (__Host-duck-csrfby default) and echoes it on the configured header (x-csrf-tokenby default) for any non-safe method; safe methods (GET/HEAD/OPTIONS/TRACE) are left alone. Both names are configurable viacsrfCookieName/csrfHeaderNameonCfg.Provider's props andclient/react's types also gain theProfilegeneric they
were missing (IProviderProps had no type param, client was typed Client<any>),
so a consumer's custom profile type now flows through instead of being erased.
5.3.2
Patch Changes
95a4eb2: Add
createdBy/updatedBycolumns to the drizzle adapter's tables (authIdentities,authCredentials,authSessions,authEvents), matching the pattern@gentleduck/iam's drizzle schemas already use.Nullable, and never set by the adapter itself (it has no actor context) - set them from
triggers or direct admin writes. updatedBy only exists on authIdentities, the only
table with an updatedAt column.
5.3.1
Patch Changes
959a8a4: Add DB-level defaults for
createdAt/updatedAton the drizzle adapter's tables (authIdentities,authCredentials,authSessions,authEvents), matching the pattern@gentleduck/iam's drizzle schemas already use.The store layer already sets both fields explicitly on every insert and update, so this
changes no observable behavior through the adapter's own API. It backstops rows written
outside that path (raw SQL, migrations, manual seeds) so created_at is never left null.
959a8a4: Add a
valkeyXxxwrapper next to every Redis-backed store, so switching betweenredis/valkeyclients is a one-line change instead of hand-wiringvalkeyAdapterinto each store's config:valkeySessionImpl,valkeyDPoPNonceStore,valkeyEvents,valkeyIdempotency(@gentleduck/auth/core/idempotency), andvalkeyLimiter(new@gentleduck/auth/limiters/valkeyentry, mirroringlimiters/redis).valkeyEventstakes a{ cmd, sub }connection pair rather than one client: once
an ioredis/iovalkey connection calls .subscribe(), it enters subscriber mode and
can no longer run ordinary commands (including PUBLISH), so the publish/command
side and the subscribe side need separate connections. valkeyPubSubAdapter is
exported standalone for callers who want to drive RedisEvents directly.
Each valkeyXxx factory lives in its own sibling file next to the matching
RedisXxx implementation, mirroring the redis.ts/valkey.ts split used
elsewhere: sessions.redis.ts/sessions.valkey.ts, dpop-nonce.redis.ts/
dpop-nonce.valkey.ts, events.redis.ts/events.valkey.ts,
idempotency.redis.ts/idempotency.valkey.ts, and limiters/redis/
limiters/valkey. adapters/valkey is a re-export barrel, mirroring
adapters/redis. Each also has its own real-server e2e suite colocated next to
the matching redis*.e2e.test.ts (sessions.valkey.e2e, dpop-nonce.valkey.e2e,
events.valkey.e2e, idempotency.valkey.e2e, valkey-limiter.e2e).
Also removes three independent hand-rolled copies of the ioredis-to-RedisLike
translation that predated valkeyAdapter (test/e2e-redis.ts's toRedisLike, used
across eleven e2e suites; events.redis.e2e.test.ts's eventsClient; and the
revocation worker's toEventsClient), replacing all of them with valkeyAdapter/
valkeyPubSubAdapter so there is exactly one implementation of that translation.
5.3.0
Minor Changes
39aaa82: Guard every server adapter's routes, and record the caller on the session.
Elysia, Fastify and Koa ran sign-in, sign-out and provider-begin with no CSRF check,
while Next, Hono, Express and Nest guarded theirs. Which adapter an application mounted decided whether a cookie-authenticated POST could be driven from another origin, and nothing in the API hinted at the difference. All seven guard now.
Every adapter also gets a guard for the application's own routes. csrfGuard was
exported the whole time but no adapter exposed it in its own middleware shape, so
protecting a route outside the mounted set meant hand-rolling the framework glue:
app.use(expressCsrf(auth))app.use(koaCsrf(auth))app.use('*', honoCsrf(auth))fastify.addHook('preHandler', fastifyCsrf(auth))app.onBeforeHandle(elysiaCsrf(auth))export const POST = withNextCsrf(auth, handler), a wrapper because the App Router gives the adapter no chain to hook
They share Csrf.GuardOptions, and each writes its own 403 rather than delegating to an
error handler the application may not have.
Sign-in dropped the caller's ip and user-agent in every adapter, so every session row
recorded a device it could not name and the anomaly detectors had nothing to compare
against. callerContext forwards only what the framework itself resolved: reading a
forwarded header in the library would take the value the caller wrote, and the host is
the only layer that knows how many proxies it trusts. Elysia and Hono resolve no address
themselves, so their context types take an optional ip the application sets.
BREAKING: a cookie-authenticated POST to the elysia, fastify or koa sign-in, sign-out or provider-begin route now requires a CSRF token and gets a 403 without one. Bearer and JWT transports keep the existing bypass, since they carry auth in the Authorization header and are not sent ambiently by a browser.
39aaa82: Configure idempotency and anomaly the way the limiter is configured.
idempotencyaccepted only the facet, so the config line read
idempotency: idempotency(memoryIdempotency()) right next to
limiter: redisLimiter({ redis, max, windowMs }). Two spellings for the same idea, and
the wrapping was easy to forget.
The key now takes a bare store as well and the engine normalises it, and
memoryIdempotency() / redisIdempotency() return a ready facet whose one config
object carries both store knobs and facet knobs. The whole line is
idempotency: redisIdempotency({ prefix: 'auth:idem', redis }). new MemoryIdempotency()
and new RedisIdempotency() still give the bare store, and wrapping an already-wrapped
facet is a no-op rather than an error.
MemoryIdempotency refused to construct unless development: true was passed, in every
environment rather than only production, which made the no-arg constructor unusable
including in the engine's own dev fallback. Only NODE_ENV=production is refused now,
and development: true is the escape hatch for it.
anomaly had no config key at all: the engine hardcoded DEFAULT_ANOMALY_CONFIG, so the
detectors could be registered but their thresholds and per-signal reactions could never
be tuned. anomaly is merged over the defaults the same way hijack is.
createAuth silently dropped two keys its type accepted. plugins cannot work because
installation is async and createAuth is not; oauth.stateSigningSecret cannot work
because the secret has to reach each provider at construction. Both now throw
AUTH_MISCONFIGURED naming the call that does work (await auth.use(plugin), and
github({ stateSigningSecret })), rather than booting an engine that quietly lacks what
was asked for.
39aaa82: Record email verification on the column, not the profile.
completeEmailVerificationwroteemailVerified: trueinto the identity's profile, and
the OIDC OP read email_verified back out of it for the userinfo claim.
updateProfile merges a caller-supplied patch without filtering keys, so a verified-email
flag living in the profile is something the account holder can set on themselves, and any
relying party trusting the email_verified claim inherits that. The emailVerified
column has been on the identity row the whole time.
Both sides read and write the column now, and beginSignUp stops seeding the profile
flag.
BREAKING: emailVerified no longer appears in identity.profile. Read
identity.emailVerified instead. Existing rows keep whatever their profile already
holds; nothing reads it any more. If an application has been trusting
profile.emailVerified, treat that value as unverified user input and reconcile it
against the column before relying on it.
39aaa82: Stamp an audit envelope onto every event that declares one.
The engine already wrapped its bus in
withAuditStamping, but the module it comes from
was never published, so the package did not build from source. It ships now.
Two sources fill the envelope, in priority order: the ambient one opened by
runWithAuditEnvelope(), which an adapter wraps request handling in once the session is
resolved, then the emitted session's own actingAs. A payload that already carries
audit is left alone. Absent therefore means "no impersonation was in effect", not
"unknown", but only for events emitted inside a runWithAuditEnvelope() scope or
carrying a session.
The audited-event list is a total record over Events.AuditedEvent, so a new event
declaring audit fails to compile until it is listed, rather than silently going
unstamped.
session.rotated now carries previousSessionId, the hashed id of the session rotated
away from, present whenever the caller supplied a previousSid. Audit consumers use it
to chain a session's lineage across rotations.
authz.revoked is declared on the event map for the IAM side to publish and duck-auth
to subscribe to, so every instance can drop cached decisions. It carries no envelope
precisely because it does not originate here.
The wrap has to be explicit rather than happening inside resolveSession, because doing
it there needs AsyncLocalStorage.enterWith, which segfaults on Bun 1.3.14-canary.
39aaa82: Export the factories, and finish the barrels.
The previous release gave every publicly constructed class a factory function, but
several were never exported, so new remained the only reachable spelling for
AnomalyFacet, HijackFacet, WebhookDeliverer, MemoryPasskeyChallengeStore,
AuthMemoryDeviceFingerprintStore, DPoPVerifier, the data-at-rest providers, the
password hashers, and the api-key / magic-link / passkey / saml / passwords impls.
The channels barrel exported one type and nothing else, so every channel had to be
imported by deep path. All six ship from @gentleduck/auth/channels now. The anomaly
barrel likewise omitted both detectors and the fingerprint store, which meant the
detectors could not be registered without reaching past it.
On the IAM side, iamEngine and iamLRUCache are exported alongside their classes.
BREAKING: three aliases in the @gentleduck/auth root now name the factory rather than
the class, so new on them stops compiling.
AuthBackupCodesFacetis nowbackupCodesFacet; the class isBackupCodesFacet.AuthInMemoryEventsis nowinMemoryEvents; the class isInMemoryEvents.
Both classes are exported under their own names, so new BackupCodesFacet(...) and
new InMemoryEvents() are the mechanical fix.
39aaa82: Make OIDC authorization codes and refresh tokens single-use under concurrency.
Both consumers read the row with a plain SELECT and then wrote. Under REPEATABLE READ a
SELECT is a snapshot read, so every concurrent transaction saw the row and, with nothing checking the write, every one of them returned it. An authorization code was redeemable as many times as it was presented at once, and two racers could both pass a refresh token's rotation-reuse detection, which is the mechanism that is supposed to catch a stolen token being replayed.
The write decides the winner now. consumeCode requires its DELETE to report exactly one
affected row; consumeRefresh adds consumed_at IS NULL to the UPDATE and requires the
same. A single affectedRows helper reads the count, since drizzle types a mutation
result on a loosely-typed MySqlDatabase as unknown and these counts are precisely
what make the operations single-use.
The consents index was declared non-unique, which broke the same operation two ways:
upsert relies on ON DUPLICATE KEY (MySQL) and ON CONFLICT (Postgres), and neither fires
without a unique key. On MySQL it appended a second consent row for the same
(identity_id, client_id) instead of replacing the scope, leaving find to return
whichever it reached first. On Postgres it raised 42P10 outright, so consent upsert did
not work at all.
BREAKING: oidc_consents_id_client becomes a unique index on both dialects. Deduplicate
existing rows per (identity_id, client_id), keeping the most recent granted_at, before
applying the migration, or the index will refuse to build.
39aaa82: Refuse a session whose identity was erased, on both resolution paths.
resolveSessionresolves a request two ways: a transport that can verify a token
statelessly, else a lookup by sid. Only the sid path refused an erased identity. The
verify path did the same lookup and returned { session, identity: null }, which is
truthy, so makeGuard passed it and a deleted user was authenticated.
The check now lives in finalize, which both paths already call, so neither can skip it
and a third added later cannot either. resolveBySid keeps its own copy because it is
exported and callable directly.
Reaching the weaker path was one line: jwtTransport and dpopTransport both define
verify and are exported from the same module as cookieTransport and
bearerTransport, so adding one to an existing compositeTransport array moved every
request onto it with no other change and no test failing.
BREAKING: resolveSession now throws AUTH_SESSION_REVOKED on the verify path where it
previously returned a result with a null identity. Callers treating a null return as
"not signed in" will see the error instead, which is what the sid path already did.
Both paths also agree on a cross-tenant token now. The tenant comparison moved into
resolveBySid, which is the only place it can run before the erased-identity throw, so
a token for a foreign tenant looks absent on either path whether or not its identity
still exists, and the identity is never looked up at all.
39aaa82: Harden session creation, rotation and expiry.
sessions.createvalidated thatfactorswas an array of at most 16 but never what was
in it. The Redis reader drops an unlisted method and a non-Date completedAt on the way
back out, so a malformed entry persisted a row that could not be read back intact. Each
entry is now checked to be { method: FactorMethod, completedAt: Date }. fingerprint
is header-derived like ip and userAgent and is now capped like them, at 256
characters.
rotateOrCreate handled credential-change after minting the replacement and then
skipped that row by id while sweeping, which only held because the store had already been
read. It sweeps the identity first and mints afterwards, so the ordering is not
load-bearing. The purpose switch gained a never default: adding a purpose without
deciding its revocation semantics is a build error rather than a silent no-op, which is
how a new purpose would otherwise ship revoking nothing.
touch extended a session's sliding expiry without checking it first, so a session whose
expiresAt had already passed, or was non-finite, was revived by the next request that
touched it, even though resolveBySid would have rejected it. It deletes the row and
fails closed.
session.created now carries the identity when the caller already holds it (sign-in,
sign-up, impersonation), so a listener does not have to re-read the row it was just
handed. createGuest and promoteGuest return the csrf token they were already minting,
and promoteGuest forwards fingerprint and actingAs, so guest device-binding
survives promotion.
BREAKING: sessions.create throws AUTH_MISCONFIGURED on a malformed factor entry it
used to persist. Callers building factors by hand should confirm completedAt is a
Date and not an ISO string.
39aaa82: Make a TOTP code single-use within its validity window.
verifyTotpanswered only yes or no, which is not enough to satisfy NIST SP 800-63B's
requirement that a verifier accept a given OTP once per validity period. The drift window spans three steps, so a code observed once, over the user's shoulder or in a phished prompt, stayed valid for about ninety seconds. That window is the whole protection on a privileged step-up.
matchTotpStep returns which time step matched, scanning the full window without
short-circuiting for the same reason verifyTotp does: stopping early leaks which step
matched. The step is recorded on the enrollment as lastTotpStep, and a code matching
that step or an earlier one is refused as a replay.
Confirmation spends its code too, so the code that enrolls a factor cannot immediately be replayed into a step-up on the enrollment it just created.
One consequence worth knowing: a user who legitimately needs two step-ups inside the same 30-second step must wait for the next code. That is the intended reading of the requirement.
Patch Changes
39aaa82: Fix three defects in the drizzle bridges.
Postgres: two
@>containment predicates ended in]::jsonb, a stray bracket that made
the SQL invalid, so findByProvider and provider linking failed outright rather than
returning nothing. Neither had a suite pointed at a real Postgres until now.
Postgres: findById on a uuid primary key raises 22P02 invalid_text_representation
for a string that is not a UUID, so an id taken straight off a request produced a 500
carrying the SQL rather than a clean unauthenticated error. Every other adapter returns
null for an unknown id, and the store contract is "unknown id", not "crash". An
unrepresentable id is treated as absent.
MySQL: json columns come back already parsed, so the Date fields nested in factors
and actingAs arrived as ISO strings against a type that promises Date, and anything
calling factor.completedAt.getTime() threw on MySQL alone. Rows are revived on read,
the way the Postgres adapter and the Redis store already do.
39aaa82: Roll back a plugin install that fails.
use()claimed the plugin id first, then subscribed its event handlers, exposed its
facet and awaited install. A throw from any of those left the id claimed, the handlers
subscribed and the facet reachable, so retrying under the same id hit "already installed"
while the half-wired plugin kept receiving events.
Providers register first, before the id is committed, so a duplicate provider id throws where the author can fix the collision and reinstall under the same id. Everything after that is undone on a throw, and the id is committed last.
Providers has no unregister, so a provider registered by a plugin that fails later
stays registered. That limit is named in the code rather than papered over.
Make the not-blank check constraints reject whitespace.
length(trim(x)) > 0only strips ordinary spaces, so a name, subject id, scope or
credential secret consisting of a tab, a newline or a form feed passed the check that
exists to refuse exactly that. Seven constraints were affected:
auth_credentials.secret, iam_assignments.subject_id and .scope, iam_policies.name,
iam_roles.name and .scope, iam_subject_attrs.subject_id.
Each dialect gets the strongest form it has. Postgres and MySQL match a non-whitespace
character (~ '[^[:space:]]' and REGEXP '[^[:space:]]'); SQLite has no regexp operator
built in, so it trims the whitespace set explicitly and compares against the empty string.
All three were verified against a real server for a tab, a newline, a carriage return, a
vertical tab, a form feed and a plain space.
Existing databases need a migration: drop each constraint and add it back in the new form.
Any row already holding a whitespace-only value has to be repaired first, or the
ALTER TABLE is refused.
5.2.0
Minor Changes
Export the idempotency and events modules, and stop callers casting the drizzle bridge.
./core/idempotencyis a new subpath export.RedisIdempotencyandIdempotencyImplwere reachable only through the bundle before, so an application wiring a production idempotency store had to cast intoConstructorParameters<typeof AuthEngine>[0]['idempotency']../core/eventsis a new subpath export, and./corenow re-exports theEventstype namespace andRedisEvents. Subscribing to the bus to persist an audit trail was not expressible without it: the engine defaults to an in-memory bus, so nothing reaches anauth_eventstable unless the application listens.createDrizzlePgBridge,createDrizzleMysqlBridgeandcreateDrizzleSqliteBridgeare now generic over the profile, matchingcreateSqlStores. They returned the base profile shape, so every caller with its own profile had to cast the whole bridge. The widening now happens once inside each adapter.findByEmailis case-insensitive in all three drizzle adapters. The uniqueness constraint isunique (lower(profile->>'email'))but the lookup compared exactly, so an identity registered asAda@example.comcould never sign in again: signup stores the address verbatim and the lookup never found it.
5.1.0
Minor Changes
bc8a9ea: Prefix the drizzle store table exports with
auth, add keyed JWT transport configuration with rotation support, and refuse the in-memory idempotency store outside development.Renamed drizzle table exports across
mysql,pgandsqlite:credentialsTableis nowauthCredentialsidentitiesTableis nowauthIdentitiessessionsTableis nowauthSessionseventsTableis nowauthEvents
The old names are gone, so update imports from
@gentleduck/auth/adapters/drizzle/{mysql,pg,sqlite}. Only the exported
bindings change — the physical table names are untouched, so no database
migration is required.
Keyed JWT transport. jwtTransport config is now a JwtTransport namespace
taking an explicit signKey plus the set of currently-valid verifyKeys, each
with a kid and per-key alg (HS256 by default, ES256 and RS256 via a PEM
private key). Keeping superseded keys in verifyKeys for an overlap window lets
already-issued tokens keep verifying through a rotation instead of failing at
cutover.
memoryIdempotency() now throws unless constructed with
{ development: true }, and always throws when NODE_ENV is production. Its
state is per-process, so it stops deduplicating as soon as a second instance
runs; it now fails loudly instead of degrading silently behind a load balancer.
The @gentleduck/iam peer range widens from an exact pin to ^, so a minor iam
release no longer takes this package out of range.
5.0.0
Minor Changes
- Restructure core into
engine/andconfig/subfolders matching duck-iam patterns. RenamedefineAuth→createAuthas primary entry point. ExtractAuthEngineTypesandAuthDefineinto dedicated types files. AddAuthprefix to all public classes.
Patch Changes
- Updated dependencies
- @gentleduck/iam@5.1.0
4.0.1
Patch Changes
- fix: strip redundant iam/auth prefixes from public exports
- Updated dependencies
- @gentleduck/iam@5.0.1
4.0.0
Major Changes
- Prefix all public exports with package namespace (
Auth*/Iam*/IAM_*/AUTH/*) so the origin is clear at the type level when both packages are imported together. This is a breaking change — all consumers must update import references to the new names.
Patch Changes
- Updated dependencies
- @gentleduck/iam@5.0.0
All notable changes to @gentleduck/auth are documented here.
Format follows Keep a Changelog;
versioning follows Semantic Versioning.
Unreleased
Added (v0.2 plan items)
- WebAuthn-MFA factor. Six new methods on
MfaFacet:beginWebauthnMfaEnrollment,confirmWebauthnMfaEnrollment,beginWebauthnMfaVerify,verifyWebauthnMfa,hasWebauthnMfa,removeWebauthnMfa. Lazy-loads@simplewebauthn/server. Counter- rollback rejection emitssuspiciousevents with signalwebauthn-mfa-counter-rollback. New'webauthn-mfa'Credential kind. (core/facets/mfa.ts,core/types/credential.ts) - KMS-envelope
DataAtRest.IAdapter(KmsEnvelopeDataAtRest) driven by a vendor-agnosticKms.IProvidercontract. ReferenceAwsKmsProviderthat lazy-loads@aws-sdk/client-kms. Encryption context binds wrapped DEKs to(identityId, field)so a leaked ciphertext can't unwrap DEKs from other rows. New subpath export@gentleduck/AUTH/core/dataAtRest. (DESIGN §1, §C5) - JwtTransport: EdDSA + live JWKS rotation. Adds
'EdDSA'(Ed25519) to the alg union with native node:crypto support. NewrotateSignKey({ signKey, verifyKey? })andretireVerifyKey(kid)methods promote a new signing key and retire the old one without recreating the transport instance; the old kid stays in the verify ring during the overlap window so already-issued tokens keep verifying. (DESIGN §3, §23 Q2) Credential.IStore.patchMetadata(id, patch, ctx). Shallow merge into a credential'smetadataand bumpversionin one step; either server-composed (SQLjsonb || $patch) or read-modify-write inside a transaction. Used byMfaFacet.confirmTotpEnrollmentfor an atomic confirm (replaces the prior delete+upsert race) and by passkey verify to advance the WebAuthn signCount on each assertion. Covered by the adapter compliance harness so every store implementation enforces the contract. (core/types/credential.ts,adapters/{memory,sql}/index.ts,adapters/__compliance__/index.ts)- AAL=3 detection on hardware-bound passkeys.
MfaFacet.eligibleAalnow returns3when the identity has a passkey credential whose enrollment metadata indicatesdeviceType === 'singleDevice'ANDbackedUp === false- matching NIST 800-63B hardware-binding. Cloud-synced passkeys (iCloud Keychain / Google Password Manager) stay at AAL=2. (core/facets/mfa.ts) - Vue / Svelte / Solid clients. Three new framework adapters
wrapping the vanilla AuthClient, each as an optional peerDep:
@gentleduck/AUTH/client/vue-createAuthVuePlugin+useSession/useSignIn/useSignOutcomposables. Vue is resolved at runtime viarequire('vue')so the auth core has no Vue in its module graph.@gentleduck/AUTH/client/svelte-createAuthStorereturning a duck-typed Svelte-store contract; no Svelte import needed at typecheck time.@gentleduck/AUTH/client/solid-AuthProvider+ Solid signals; uses realsolid-jstypes.
Security
Five rounds of focused security review against the auth-critical surface. 42 fixes shipped; each is covered by a regression test (600 -> 683 vitest tests over the five rounds).
Round 5 (AuthRoot wiring / sessions matrix / anomaly detectors):
- P0:
AuthRoot.resolveSession(req, { expectedTenantId })now refuses a session whosetenantIddiffers from the request's expected tenant. Without this, a SID/JWT minted under tenant A presented at a tenant-B endpoint returned tenant-A's identity. (core/auth.ts) - P0:
FlowsFacet.completeSignUpnow routes throughSessionsFacet.rotateOrCreate({ purpose: 'guest-promotion', previousSid })so the prior guest SID is revoked atomically with the new user SID. DESIGN §37 mandated this; the priorcreate()call bypassed the fixation guarantee. New optionalpreviousSidarg. (core/facets/flows.ts) - P0: End-to-end CSRF auto-enforcement on every server adapter
(express / hono / next).
SessionsFacet.createreturns a plaintextcsrfToken;CookieTransport.issueemits the__Host-duck-csrfcookie when supplied; mounted signin / signout / provider-begin routes callcsrfGuard(auth, req)first. Double-submit (Layer-2) is correctly skipped when no session exists yet (signin / signup-begin), so Layer-1 (Sec-Fetch-Site) alone protects the unauthenticated state-changes. (core/csrf.ts,core/transport/cookie.ts,core/facets/sessions.ts,server/{express,hono,next}/index.ts) - P1:
AuthRoot.strict({ env: 'production' })now catches an explicitly-passedNoopLimiter(via__isNoopLimiterbrand) and walks ALL three stores (identities + sessions + credentials) for the memory-adapter heuristic. Closes "mixed adapter" misconfigs where memory sessions slipped past the production check. (core/auth.ts) - P1:
impossibleTravelDetectorno longer no-ops when lat or lon is0(Equator / Prime Meridian) and rejects negative elapsed-ms (clock-skew or attacker-planted lastSeen) before the speed calc. PathologicalmaxKmPerHour(zero, negative, non-finite) throws at construction. (core/anomaly/impossible-travel.ts) - P1:
deviceFingerprintDetectorIPv6 subnet now expands compressed addresses (::1,2001:db8::1) before taking the /48 prefix. Previous naivesplit(':').slice(0,3)collapsed unrelated networks to the same subnet key, suppressingnew-deviceMFA prompts. (core/anomaly/device-fingerprint.ts)
Round 4 (MFA / dataAtRest / identities / cookie):
- P0:
BackupCodesFacet.generateno longer usesMath.random()to top off codes or modulo-bias the base64url input over the readable alphabet. NewgenerateBackupCodedraws every character fromcrypto.randomByteswith rejection sampling against the upper modulo-bias tail. Restores the nominal32^8 ~ 40 bitsentropy (previous implementation was effectively ~30 bits). (core/mfa/backup-codes.ts) - P1:
BackupCodesFacet.verifyusestimingSafeEqualon the per-code hash and drives the credentials loop to completion so early-exit timing cannot leak match position. Successful consumption now soft-revokes (revoke()) rather than hard-deletes the row, so a subsequent replay attempt surfaces against an already-revoked audit trail instead of a phantom code. (core/mfa/backup-codes.ts) - P1:
RememberMeFacet.revoke(identityId, credentialId)now requires identity ownership and refuses to delete rows that belong to a different identity. Without this, a consumer surfacingrevoketo an HTTP route that takes?credentialId=would let any authenticated user revoke any other user's trusted-device row. (core/mfa/remember-me.ts) - P1:
IdentitiesFacet.getByEmail(andbulkCreatelookup) nowemail.trim().toLowerCase()the input. Without normalization,Bob@x.comandbob@x.comcreated duplicate identities, password resets went to only one, and case variants leaked via differential responses. (core/facets/identities.ts) - P2:
AesGcmDataAtRestacceptspreviousKeysso ciphertexts written under a rotated kid can still be decrypted. Previously thekidwas parsed from the ciphertext but discarded - every key rotation stranded existing data with silent auth-tag mismatch. Duplicate kids across(current, previousKeys)throw at construction. (core/dataAtRest/aes-gcm.ts) - P2:
CookieTransportconstructor enforces all three__Host-prefix invariants: noDomain,Path=/, andSecure=true. Browsers silently drop cookies that violate them - fail-fast catches the misconfig in CI rather than at runtime as "session never sticks". (core/transport/cookie.ts) - Carry-over P2 (Round 3): Magic-link
_sendno longer forwards the channel adapter'serror.messageto the caller (would reflect token plaintext on SMTP/SES errors). (providers/magic-link/index.ts) - Carry-over P2 (Round 3): Webhook signatures bind the timestamp
(
X-Duck-Timestampheader), constructor SSRF-guards endpoint URLs against loopback / private / link-local / cloud-metadata hosts. (core/webhooks/index.ts)
Three prior rounds (JWT, DPoP, FlowsFacet, OAuth refresh, IdempotencyFacet, M2M, Hijack, OIDC, SQL examples, passkey WebAuthn, CSRF, compliance presets) detailed below.
Round 3 (passkey / magic-link / CSRF / webhooks / compliance):
- P1: Passkey counter-rollback detection - previously dead code
(
newCounterwas read thenvoid-discarded). Now rejects onnewCounter !== 0 && newCounter <= stored, emits apasskey-counter-rollbacksuspicious event, and advances the stored baseline viaCredential.IStore.patchMetadataso the baseline tracks the authenticator across assertions (closes the earlier "needs metadata-update method" gap). (providers/passkey/index.ts) - P1: Passkey
completenow binds the credential to (a) the caller-suppliedemailhint by assertingcred.identityId === findIdentityByEmail(email).id, and (b) the WebAuthnresponse.userHandlebytes. Previously the email was a UI hint only and the userHandle was ignored - letting an attacker who possessed any valid passkey and knew a victim's credential ID (not a secret) sign in as the victim. (providers/passkey/index.ts) - P1: Passkey registration now hashes the identityId to a stable
32-byte WebAuthn
user.idvia sha-256 instead ofTextEncoder. Browsers silently truncate user.id at 64 bytes; long composite IDs (ULIDs,tenant:usernamespaces) collided on the authenticator and surfaced the wrong identity on discoverable-credential sign-in. (providers/passkey/index.ts) - P2: Webhook signatures now bind the timestamp:
signWebhookBody(secret, body, timestamp)HMACs${ts}.${body}and the deliverer emitsX-Duck-Timestamp.verifyWebhookSignaturetakes{ timestamp, toleranceMs }and rejects replays outside the window (default 5 min). (core/webhooks/index.ts) - P2: Webhook constructor now SSRF-guards endpoint URLs. Refuses
non-HTTPS by default (escape hatch
allowInsecure: true), refuses loopback / private / link-local / cloud-metadata hosts even withallowInsecure. Closes the metadata-server exfil vector for any deployment that populates the endpoint list from tenant input. (core/webhooks/index.ts) - P2: Magic-link
_sendno longer forwards the channel adapter'serror.messageto the caller. SMTP / SES error objects commonly embed the rendered message body - which for magic-link is the plaintext token URL - into their error metadata. Previously surfacing it viaAUTH/PROVIDER_FAILED.detailbroke the single-use invariant by reflecting the token in the HTTP response + any error sink. (providers/magic-link/index.ts) - P2: CSRF
origin-onlymode now refusesSec-Fetch-Site: nonewhen no Origin allowlist is configured. Direct user-navigation POSTs- spoofable non-HTTP contexts both surface as
none- gating on Origin was the only Layer-1 defense and the prior code fell open when the allowlist was empty. (core/csrf.ts)
- spoofable non-HTTP contexts both surface as
- P2:
applyCompliancePresetbrands the returned config with a non-enumerable__compliancePresetmarker soAuthRoot.strict()can detect the preset and auto-callassertComplianceStrict. Previously,applyCompliancePresetratcheted only the numeric knobs (password length, session TTLs) -minAal,requireDataAtRest,requiredStrictCheckssilently dropped unless the operator remembered to callassertComplianceStrictby hand. NewreadCompliancePresethelper exposes the marker. (core/compliance.ts) - P2:
ApiKeysFacet.verifyruns a synthetic sha256 + store lookup on the prefix-mismatch path so timing signature matches the success path. Closes a timing oracle that distinguished valid-format junk from "live api-key wrong secret". (core/facets/apikeys.ts)
Round 2 (M2M / Hijack / OIDC / adapter consistency):
Round 2 (M2M / Hijack / OIDC / adapter consistency):
- P0:
M2MFacet.exchangenow signs the granted scopes into the JWT via a newTransport.IssueOpts.scope+JwtPayload.scope. ThescopeMode: 'intersect'/'strict'mechanism is now enforced on the wire - resource servers branch onpayload.scope. Previously the granted set lived only in the envelope and was dropped on serialization. (core/facets/m2m.ts,core/transport/jwt.ts,core/types/transport.ts) - P1:
HijackFacet.evaluateevaluates BOTH IP and UA branches and picks the strongest reaction (precedence: revoke > mfa > rotate > ignore). Prior implementation returned on the first non-ignore branch, silently downgrading the UA reaction. Also: one-sided fingerprint absence is now treated as drift (downgraded to rotate) so a request stripping the User-Agent header doesn't bypass detection. (core/facets/hijack.ts) - P1:
M2MFacetnow refuses to mint a token when the caller-suppliedtenantIddisagrees with the credential's own tenant; falls back to the credential's tenantId when the caller omits it.ApiKeysFacet.verifyreturns the row's tenantId. (core/facets/apikeys.ts,core/facets/m2m.ts) - P1:
ApiKeysFacet.verifyruns a synthetic sha256 + store lookup on the prefix-mismatch path so the timing signature matches the success path. Closes a timing oracle that distinguished "live api-key format" from "junk". (core/facets/apikeys.ts) - P2:
buildOidcDiscoveryparses + canonicalizes the issuer vianew URL(), refuses non-HTTPS in production (escape hatchallowHttp: true), spreadsextraClaimsBEFORE canonical fields so the extension hatch cannot shadowissuer/jwks_uri/etc., and drops'none'fromtoken_endpoint_auth_methods_supportedby default. (oidc/index.ts) - P2: Drizzle adapter examples - pg/mysql/sqlite
findByEmailandfindByProviderSubaligned withfindById's null-tenant semantics (null-tenant rows are "global identities" reachable from any tenant). Previously inconsistent - surfaceable as duplicate identity creation on next OAuth callback. (adapters/sql/examples/drizzle-{pg,mysql,sqlite}.example.ts) - Docs: M2MFacet class JSDoc now explicitly notes that revocation
is lazy (JWT lives until
expregardless of api-key revocation) so operators don't assume "revoke == kill". (core/facets/m2m.ts)
Round 1 (JWT / DPoP / FlowsFacet / OAuth refresh / IdempotencyFacet):
- P0:
completePasswordResetnow requiresmetadata.kind === 'password-reset'. Thekind: 'recovery'credential row is shared by password-reset, email-verification, account-deletion, and signup-flow tokens. Without this assertion, an attacker holding any recovery-kind token (e.g. an email-verification link) could callcompletePasswordResetand set the victim's password. (core/facets/flows.ts) - P1:
resolveBySid+JwtTransport.verifynow enforceactingAs.expiresAt. The 1-hour cap on impersonation was previously informational only - sessions stayed live for the full session TTL (default 7d). (core/facets/sessions.ts,core/transport/jwt.ts) - P1:
JwtTransport.verifynow reconstructssession.freshfrom a newfrshclaim (rotatedAt epoch) instead of hard-codingtrue. The freshness gate that protectscompletePasswordReset/checkStepUpin cookie mode now applies identically in JWT mode. NewJwtTransport.IConfig.freshnessMsknob (default 5min) matches the cookie-session default. (core/transport/jwt.ts) - P1: OAuth refresh-token rotation closes the TOCTOU race between
findByHashedSecretandrevoke + upsert. The row is claimed viaCredential.rotate(id, sameSecret, expectedVersion)(CAS on version) before the IdP exchange runs; concurrent refreshes get AUTH/STALE_WRITE on the second rotate and revoke the family per RFC 6749 §10.4. (providers/oAUTH/core/refresh.ts) - P1:
IdempotencyFacet.handleno longer double-executes when a worker loses theclaim()race. Both the in-memory and Redis stores filter the claim tombstone, and the facet polls (bounded exponential backoff, default 5s) until the originator'sput()lands - returning a 409 if the originator crashed without writing. (core/facets/idempotency.ts) - P2:
AnomalyFacet.decide()now fails CLOSED on non-finite signal scores. A buggy detector emittingNaNpreviously sank into'allow'via comparison short-circuits. (core/facets/anomaly.ts) - P2:
IdempotencyFacet.handlenow takes an optionalidentityIdand scopes the cache key by it. A cached response minted for Alice can no longer be served to Bob under the same tenant + key. For anonymous routes the scope falls back to_anon(callers should fingerprint by IP+UA upstream). (core/facets/idempotency.ts) - P2:
JwtTransportconstructor rejects duplicate-kid entries inverifyKeys(silent last-wins) and rejectssignKeywhose alg/key mismatches a verifyKey under the same kid. HS256 enforces key equality; asymmetric algs only enforce alg parity (verifyKey holds the public counterpart of signKey's private material). (core/transport/jwt.ts) - P2:
DPoPVerifier.verifynow rejects authed-request proofs whoseathclaim is missing (RFC 9449 §4.3) AND rejects proofs carryingathwhen no access token is supplied to verify against. (core/transport/dpop.ts) - P2:
DPoPVerifieraccepts an optionalexpectedNonce(string or thunk) per RFC 9449 §8/9. Lets multi-pod deployments tighten the replay window beyond the local jti store via a rotating server-issued nonce. (core/transport/dpop.ts) - P2: OAuth refresh now emits a
suspiciousevent with signaloauth-refresh-unknown-rowwhenfindByHashedSecretreturns null. Replay of a hard-deleted (post-GC) row token surfaces as a leak signal operators can route to oncall, even though the familyId is no longer available to auto-revoke. (providers/oAUTH/core/refresh.ts)
Added
- Namespace migration: every public exported type lives inside its owner
namespace (
SessionsFacet.IConfig,Captcha.IVerifier,Identity.IIdentity).scripts/audit-namespaces.tsenforces the convention.scripts/inline-types-into-namespace.tsautomates the migration. - CLI:
migrate <pg|mysql|sqlite>emits CREATE TABLE DDL matching theSqlBridgerow contract. Tenant + composite indexes included. - CLI:
keys rotate hs256mints a new HS256 secret + rollover snippet that keeps the previous kid onverifyKeysfor in-flight JWTs. Default--new-kidincludes a random suffix to prevent collisions on sub-second rotation. - CLI:
emit-openapidynamic-imports the localauth.tsand prints the OpenAPI 3.1 spec (or writes via--out=<path>). @gentleduck/AUTH/test:createTestAuth()helper that builds anAuthRootwired to in-memory adapters with aTestAuth.IOverridesshape for surgical swaps in test code.AnomalyFacet.decide()+IDecisiontype:'allow' | 'step-up' | 'deny'recommendation per signal set.IConfig.reactionsmap for per-kind overrides.unregister(id)complementsregister().AnomalyFacet: NaN / non-finite scores from a buggy detector fail CLOSED ('deny'), not silently allow.adapters/sql/examples/: parallel Drizzle reference implementations for MySQL and SQLite alongside the existing pg example.SECURITY.md: dedicated security policy + Deployment Hardening Guide (15 items covering transport, JWT rotation, DPoP, rate limiting, idempotency, password hashing, magic-link channels, OAuth reuse detection, CSRF, MFA step-up freshness, impersonation, anomaly detectors, PII redaction).
Changed
AnomalyFacetconstructor acceptsPartial<IConfig>and merges withDEFAULT_ANOMALY_CONFIGso consumers can supply just the field they want to tweak.cli migrate mysqlemitsVARCHAR(64)for primary-key + index columns (MySQL refusesTEXT PRIMARY KEYwith ERROR 1170). Same dialect emitsCREATE INDEXwithout theIF NOT EXISTSguard (unsupported pre-8.0.29).cli migrateandcli emit-openapi--out=<path>flag is path-contained to the current working directory (refuses../../...traversal).- Drizzle reference examples (pg / mysql / sqlite):
merge(),insertProviderLink(),deleteProviderLink()are now tenant-scoped. Schemas now declare the(kind, secret)composite index plusexpires_at/absolute_expires_atsession indexes that the bridge contract documents but had been omitted. - Drizzle mysql + sqlite examples:
listByIdentityuses!== undefinedfor the tenant filter so empty-string tenant ids don't silently drop the predicate. sqlitefindByProviderSubmatches links whose storedproviderSubis null when the lookup sub is null.
Documentation
STATUS.mdrefreshed: 617 -> 626 -> 632 tests passing across 68 files; v0.1 coverage updated from ~80% to ~98% of v1.0 MUST surface; tooling list updated to mention the audit + inline scripts.DESIGN.md[pending v0.2]markers removed from sections that have shipped (passkey, observability, OpenAPI, i18n, CLI, test helpers). §22 v1.0.0 scope rewritten to reflect actual shipped vs. follow-on. §23 open-questions split into "closed" (WebAuthn library, DPoP, attestation policy) vs. "still open" (tenant resolution, jose migration, GeoIP source, OIDC cache, federation conflict UX, native SDKs).README.mdsurface tables synced: 6 OAuth providers, passkey discoverable+username, SAML, SQL bridge + Drizzle reference impls, 6 channels, 9 server adapters, OIDC + OTel + i18n + test helper subpath exports listed. Status table reflects v0.1 reality.
Removed
scripts/add-namespaces.tsandscripts/add-namespaces-v2.ts(superseded byaudit-namespaces.ts+inline-types-into-namespace.ts).
0.1.0 - prior to this changelog
Initial pre-publish surface. Faceted AuthRoot with 14 facets, 4
transports, memory + redis + sql-bridge adapters, 11 providers,
9 server adapters, 2 clients, 6 channels, CLI, OpenAPI generator, OIDC
discovery, OpenTelemetry instrumentation, i18n catalog, adapter compliance
harness, bundle benchmark, threat model. See STATUS.md for the full
shipped surface.