chore: update version and changelog - #259
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
seamless-auth-api@0.8.0
Minor Changes
0e6664d: Give WebAuthn challenges their own store, with an expiry and one-time use.
Challenges lived in a single
users.challengecolumn shared by registration,login and step-up. Three consequences, all fixed here:
already in flight for the same user, and a second tab invalidated the first.
Challenges are now keyed by user and flow, so registration, login and step-up
can be outstanding at once.
until some later flow happened to overwrite it. The
timeoutin the credentialoptions is only a hint to the browser and was never enforced. Challenges now
expire server side after five minutes, comfortably longer than that hint so no
legitimate ceremony is cut short.
reads it, before anything else can fail, so an attempt that fails leaves
nothing redeemable behind.
A magic link completing also spends any half-finished WebAuthn ceremony for that
user, preserving what the old defensive clear did.
users.challengeandusers.challengeContextare no longer read or written.They are left in place so this release can be rolled back, and should be dropped
in a follow-up once it has run in production.
8b74a80: Support attestation, and validate it against the FIDO Metadata Service.
Registration hardcoded
attestationType: 'none', so authenticators neveridentified themselves and there was nothing for the FIDO Metadata Service to
validate. FIDO Server Requirements v2.3 requires a server to validate
attestation certificate chains and to support validation through that service.
authenticator_policy.attestationnow chooses.nonestays the default, whichsuits a consumer deployment: attestation identifies a user's hardware and most
relying parties have no use for it.
directrequests a statement, and themetadata service is prepared at startup so the attestation verifiers validate
against it.
authenticator_policy.requireKnownAuthenticatordecides what happens to anauthenticator the metadata service does not list. False, the default, registers
it anyway; true refuses it.
Credentials now record
attestationFormatandattestationVerified, so an auditcan tell an unattested credential from one whose attestation was actually
checked. Neither is recoverable after the fact, so existing credentials report
neither.
The metadata service never blocks startup. A blob that cannot be fetched is a
degraded state, not a reason an authentication server should refuse to start, so
it is logged and registration continues without metadata validation.
requireKnownAuthenticatoris deliberately not honoured in that state, becauserefusing every registration on a transient network failure is worse than the risk
it guards against.
Changing
attestationneeds a restart, because the metadata service is preparedonce at boot.
Requires
@seamless-auth/types0.14.0.365bc95: Read the JWKS public keys document under one name, and document rotation properly.
Fixes a deployment trap.
validateEnvs.shrequiredJWKS_PUBLIC_KEYS, and theconfiguration reference and
.env.examplenamed only that, but token verification readSEAMLESS_JWKS_PUBLIC_KEYS. A deployment that set exactly what this API asked forstarted cleanly and then threw on every JWT verification, because
getPublicKeyByKidcould not find the secret. The failure arrives at the firstauthenticated request rather than at boot, which is the worst place for it.
Everything now uses
SEAMLESS_JWKS_PUBLIC_KEYS: the entrypoint check, the/.well-known/jwks.jsonhandler, the configuration reference, and.env.example.Breaking for anyone setting only the unprefixed name, who is already broken and
does not know it. After this they fail to start, with the variable named, instead of
serving a deployment that cannot verify a token it just issued.
Rotation is documented rather than implemented. The acceptance criteria in the
rotation issue are met by the read path that already exists: the document is a list,
every key in it is published and can verify, and only the active kid signs. What was
missing was a written procedure, which
docs/production-operations.mdnow carries asthe three-step overlap (add, flip, retire), including why the steps cannot be
collapsed and why key ids must be environment-variable safe.
The server deliberately does not rotate its own keys. It has no secret-store write
path, and environment variables are fixed for a process's lifetime, so a server that
rotated could not observe the result without a restart it cannot trigger. The document
belongs to whatever manages the secrets.
Accordingly the empty
ensureKeys()production branch is removed, along withinitKeys, their specs and the dev-stack invocation. It advertised a runtime rotationcapability that is not going to exist, and its development branch wrote a keypair to
./keysthat nothing has ever read:signingKeyStorekeeps dev keys under./keys/devand generates them lazily.642b823: Allow hardware security keys to be enrolled.
GET /webauthn/register/startpinnedauthenticatorAttachmenttoplatform, whichhid roaming authenticators from the browser picker entirely, so USB and NFC security
keys could not be registered at all. Only built-in authenticators (Touch ID, Windows
Hello, Android biometrics) were reachable.
Registration now leaves the attachment unset by default, so the browser offers both
kinds. Callers that want to narrow the picker can pass
?attachment=platformor?attachment=cross-platformonregister/start; anything else is rejected with a 400.This changes the default enrolment experience: users who previously saw only the
built-in authenticator will now also be offered a security key. Deployments that
genuinely want the old behaviour should pass
?attachment=platform.fdf9613: Correlate audit events to the session they happened in.
Audit events gain
session_id, andGET /admin/auth-eventsaccepts asessionIdfilter, so a suspicious session can be turned into its event historyand an event traced back to the session it came from.
The session is read from the request rather than passed at each of the 135 log
call sites. The bearer middleware already sets it for any access-token call, so
authenticated events correlate without any of those sites changing, and anything
before a session exists stays null. A caller can still name a session
explicitly, which is what an administrator acting on someone else's session
needs.
The column is nullable and not backfilled. The session for historical events is
unrecoverable.
Requires
@seamless-auth/types0.11.0.30c3971: Record who performed an administrative action.
Audit events gain
actor_user_id. An administrator acting on someone else'saccount is now recorded with the target in
user_idand the administrator inactor_user_id, so the trail no longer reads as though the user did it tothemselves.
GET /admin/auth-eventsaccepts anactorUserIdfilter, whichanswers "what did this administrator do" rather than only "what happened to this
user".
Two administrative actions that previously wrote no audit event at all now write
one:
The user deletion is now awaited before the response, so a failure surfaces as a
500 rather than a success with the account still present, and the audit event
records a deletion that actually happened.
The column is nullable and not backfilled. The actor for historical events is
genuinely unknown, and inventing one would be worse than leaving it empty.
Requires
@seamless-auth/types0.10.0.c60c3e3: Answer a schema validation failure with the documented error body instead of a raw
ZodError.Behaviour change to the error contract. A request that fails its route's
params,queryorbodyschema was refused withres.status(400).json(error), passing theZodErrorstraight to the serializer. That produced{ "name": "ZodError", "message": "<the issues, JSON encoded into a string>" }, whichhas no
errorkey at all. Every route declares400: ErrorSchema, whereerrorisrequired, so the response violated the contract the route published for itself, and a
client had nothing stable to branch on.
Nothing caught it. The response schema check is installed by the handler wrapper, and
validation fails before that wrapper runs, so the mismatch was never even logged.
For a consumer the practical effect was worse than a missing code. The React SDK reads
errorand falls back tomessage, so with noerrorpresent,registerPasskey()surfaced the entire encoded issue list as
error.message, ready to be rendered to auser by an app doing the documented thing with an unrecognised failure.
Validation failures now answer with:
{ "error": "invalid_request", "message": "Request failed schema validation.", "details": { "issues": [{ "path": ["attachment"], "code": "invalid_value", "message": "..." }] } }errorcarries the stable code.detailsnames the rejected fields, following the samereasoning as
AdminValidationErrorSchema, which exists because a plain error schemawould strip that list before it reached the caller. Issues are mapped field by field
rather than passed through, so a refusal does not echo the submitted value back.
defineRoutenow declaresValidationErrorSchemaas the400for any route thatvalidates a request, so
openapi.jsondocuments the response that validation actuallyreturns. A route that already declares a richer
400keeps it.errorstays requiredeverywhere, so a consumer reading only that field is unaffected, and
detailsisadditive.
A throw that is not a
ZodErroris passed to the error handler rather than beingreported as a bad request, since it means a server fault rather than a malformed
request.
40e17ca: Refuse synced passkeys by default, and let a deployment restrict authenticator models.
Breaking. Read this before upgrading.
authenticator_policy.syncedPasskeysdefaults toblock. A multi-devicecredential is synced by a platform password manager, so its private key exists
somewhere outside the authenticator that created it. Every iCloud Keychain and
Google Password Manager passkey is one. On upgrade, a deployment relying on
platform passkeys stops enrolling them and registration answers
403 { "error": "synced_passkey_not_allowed" }.To keep the previous behaviour:
AUTHENTICATOR_POLICY={"syncedPasskeys":"allow", ...}This closes the gap between what the product did and the design position it was
documented as holding, which was blocked by default with the agency able to
enable. Existing credentials are unaffected; this governs new registrations.
The decision is made on backup eligibility rather than current backup state.
A credential that can leave the device is the exposure whether or not it already
has, and judging on current state would let one register while unsynced and sync
afterwards.
Also adds
aaguidAllowListandaaguidDenyList, which restrict whichauthenticator models may register. The deny list is applied first, so a model can
be excluded even when a broad allow list would admit it. Both need
attestation: 'direct'to mean anything, since an authenticator that was neverasked to identify itself reports no usable AAGUID; an allow list set without it
refuses everything, and the server says so at startup rather than leaving it to
be discovered one failed enrolment at a time.
Refusals are distinguishable,
synced_passkey_not_allowedandauthenticator_not_allowed, and each is recorded as a failed registration withthe reason.
1f5d98c: Require identity proofing on admin-assisted device replacement.
Breaking.
POST /admin/users/:userId/recovery/device-replacementnowrequires a
proofingobject and answers 400 without one:{ "proofing": { "method": "in_person", "evidenceRef": "TICKET-1042" } }methodisin_personorremote_exception. A remote exception is refusedunless it names an
approver, so taking the weaker path is deliberate andattributable.
evidenceRefis a pointer such as a ticket number, not theevidence itself, because it is written to the audit trail where identifiers are
redacted.
This endpoint revokes every session, removes every passkey and disables TOTP. It
previously recorded nothing about how the operator established who they were
talking to, which made a recovery impossible to review afterwards.
The audit event now carries the proofing record and the acting administrator.
The acting admin currently rides in event metadata; it moves to a first-class
column when
auth_eventsgains one.Callers sending an empty body and relying on the clearing defaults must now send
proofing. Those defaults are unchanged. Requires
@seamless-auth/types0.9.0.09d3df3: Ask for exactly the user verification that will be enforced.
Registration advertised
userVerification: 'preferred', telling theauthenticator verification was optional, and then rejected a response that
skipped it, because SimpleWebAuthn's
requireUserVerificationdefaults to trueand this server never set it. A user on an authenticator that skips verification
completed the whole ceremony and failed at the last step, having never been asked
to verify. Authentication separately asked for
required, so the two halvesdisagreed.
authenticator_policy.userVerificationnow drives both, for registration andauthentication, so what the browser is asked for and what the server accepts come
from one value and cannot drift. It accepts
required,preferredordiscouragedand defaults torequired.The default does not change what is accepted, since that was already enforced. It
changes what is asked for, so an authenticator is told to verify rather than
being allowed to skip and be rejected afterwards.
Step-up deliberately still requires verification regardless of the policy. It
exists to re-verify the human, and without verification it is a second signature
from a key the session already proved it holds.
Requires
@seamless-auth/types0.13.0.d78bfc1: Refuse a request from an origin that is not allowlisted, instead of running it.
The CORS origin callback rejected an unknown origin by returning
falseratherthan an error, so the handler that exists to turn a rejection into a
403keyedoff an error message that was never raised and could not fire. What actually
happened was that
corsomitted theAccess-Control-Allow-Originheader andcalled
next(): the route ran, and only the browser discarded the response. Foran authentication server that is the wrong way round, since a disallowed origin
should not be able to make the server act.
An unlisted origin is now refused with
403 { "message": "CORS policy does not allow this origin." }before the route runs, preflights included.Two kinds of request are deliberately still allowed. One carrying no
Originheader at all, which is every server adapter, backend and command-line caller.
And a same-origin request: a browser sends
Originon every state-changingrequest, same-origin ones included, so without this the admin console at
/consolewould need its own host inAPP_ORIGINSdespite being served by thisvery process. That comparison is on host rather than scheme, so it does not
silently depend on
TRUST_PROXYbeing set behind a TLS-terminating proxy.The refusal no longer sets an
Access-Control-Allow-Originheader naming thefirst allowed origin, which disclosed part of the allowlist to a caller that was
not on it and helped the browser not at all. It is recorded as one
request_suspiciousevent carrying the real client address and user agent, withthe rejected origin in an
originmetadata field rather than inipAddress.e58ef6c: Stop recording a WebAuthn registration success before anything is registered.
GET /webauthn/register/startloggedwebauthn_registration_successat the endof options generation, before the client had done anything and before any
credential existed. Every abandoned or failed registration produced a success
event, so registration counts, dashboards and anomaly detection were all
measuring the wrong thing. Because outcome is derived from the
_successsuffix, those events were also counted as successful WebAuthn activity in the
metrics.
Issuing options now logs
webauthn_registration_challenge, matchinglogin_challengeon the login path. It categorises aswebauthnwith outcomeother, so it no longer inflates the success figures. The realregistration_successstays where it belongs, on verified registration in/webauthn/register/finish.webauthn_registration_successis removed from the declared event types, sinceit is now emitted nowhere and this repository deliberately prunes types nobody
writes so consumers do not filter and alert on names that never arrive. Stored
events keep that type and remain readable and filterable by exact type; they are
no longer swept into the
webauthncategory filter.15005f2: Make session lifetimes configurable, and give the idle bound a chance to fire.
Session expiry came from two hardcoded constants, both one day. Because they
were equal,
idleExpiresAtandexpiresAtalways landed on the same instant,so the idle bound could never fire before absolute expiry. In practice there was
no idle timeout at all, despite the session model carrying the column and the
lookup queries filtering on it.
Two changes:
refresh_token_ttl, whichalready existed and was already reported to clients as
refreshTtl. It wasnot previously applied to the session row, so an instance with
REFRESH_TOKEN_TTL=30dtold clients thirty days and expired the session afterone. Setting it now does what it says.
session_idle_ttl(
SESSION_IDLE_TTL), default8h.Behaviour change. On stock configuration a session that goes unrefreshed
now ends after 8 hours rather than 24. Any client refreshing normally is
unaffected, because rotation resets the bound and access tokens are far shorter
lived; only genuinely idle sessions end sooner. Instances that want the previous
behaviour can set
SESSION_IDLE_TTL=1d, and deployments with a stricter posturetypically want 15m to 30m.
An instance that previously relied on
REFRESH_TOKEN_TTLbeing longer than oneday will see sessions live as long as that value now actually says, which is
longer than before. Check that value if it was set to something large on the
assumption it was inert.
Requires
@seamless-auth/types0.8.0.6ff06c4: Add the FIDO2 conformance test interface behind an environment flag.
The FIDO2 conformance test tools drive a server through a fixed message interface
rather than through its own API, so with no such surface conformance
self-validation could not be run at all. That is the first gate of FIDO Functional
Certification, and the only part of it reachable without an assessor, a customer or
a sponsoring agency.
Four paths now serve that interface under
/conformance, and only whenFIDO_CONFORMANCE_MODE=true. The flag is refused underNODE_ENV=production,matching
DISABLE_AUTH_RATE_LIMITSandALLOW_UNCREDENTIALED_DELIVERY_SECRETS.With the flag unset nothing is registered: the routes never reach Express, never
reach the OpenAPI document, and the paths answer the ordinary 404. Enforcement is
in the tests, not in the documentation, because the surface takes no
authentication, issues no sessions, honours whatever attestation conveyance the
caller asks for instead of the deployment policy, and is expected to accept
malformed input.
What a run validates is the shipping WebAuthn verification: the same library calls
the real controller makes, the same advertised algorithm list, and the same RP ID
and origins from
system_config. Storage is not shared. Conformance users andcredentials are held in memory, because the tools invent hundreds of accounts and
replay ceremonies on purpose.
Three optional variables point metadata verification at the tools rather than the
production FIDO Metadata Service, which signs its blobs with a different root:
FIDO_CONFORMANCE_MDS_URLS,FIDO_CONFORMANCE_MDS_ROOT_CERT_FILEandFIDO_CONFORMANCE_METADATA_DIR. Conformance mode also brings the metadata serviceup whatever this deployment has configured, since the surface honours the
conveyance the tools ask for.
6658a49: Make
requireKnownAuthenticatorrefuse a credential that cannot be looked up,and stop claiming attestation was verified when it was not.
Behaviour change. A deployment running
attestation: 'direct'withrequireKnownAuthenticator: truenow refuses a credential that self attests orpresents no attestation. Authenticators that ship no attestation certificate can
no longer enrol there. Both settings are off by default, so only a deployment
that explicitly asked for known authenticators is affected, which is the
population the change is for.
The setting only ever set the metadata service to a strict verification mode, and
that mode is consulted per attestation format, only for a statement carrying a
certificate chain. Every format requires one except
packed, which is alsodefined without: a statement the credential signs with its own key. Nothing
stands behind such a statement and nothing can be looked up for it, so a
credential presenting one was admitted without the setting ever being consulted.
An agency that had asked for known authenticators only was getting no
restriction at all against exactly the authenticators the setting exists to keep
out. The refusal reuses the existing
authenticator_not_allowederror, with thereason recorded in the audit event.
The rule applies only under
direct. Undernoneno credential presents achain, so it would refuse every registration rather than restrict anything, and
the server now logs that misconfiguration at startup the way it already does for
an allow list set without attestation.
credentials.attestationVerifiedis now true only when the metadata serviceactually held a statement for the credential's AAGUID. It was derived from the
attestation format and whether the service had come up, so a self attested
credential was recorded as verified against metadata it had never been compared
to. That field exists so an audit can tell an unattested credential from a
checked one, and it could not.
A new nullable
credentials.attestationTypecolumn recordsnone,selforbasic. The format alone cannot separate the last two, since amanufacturer-signed statement and one a credential signed for itself are both
packed. Existing rows are left null; neither value can be recovered after thefact.
c502782: Make
POST /loginnon-enumerable with decoy pre-auth tokens.Breaking behaviour change.
POST /loginno longer returns401. Anidentifier with no usable account, which previously meant an unknown identifier,
an unverified account, or an account with no permitted continuation method, now
gets
200with a decoy ephemeral token: real, signed, and indistinguishablefrom one issued to a genuine account.
A client that branched on
401to mean "no such user" will now follow the normalcontinuation flow instead, and the failure surfaces at the continuation step, as
a wrong OTP or an assertion that cannot verify, rather than at login. That is the
point of the change: there is no longer an answer to give.
@seamless-auth/reactand
seamless-clialready fall back to a default method list rather than reading401as a terminal state, but any caller that special-cases it needs updating.Returning
200for an unknown identifier is worth nothing unless the nextrequest keeps the secret, so all fifteen endpoints that accept a pre-auth token
now answer for a decoy the way they answer for a real account. OTP sends report
success without sending. OTP and TOTP verifies fail the way a wrong code fails.
The magic link request returns its usual "if an account exists" body and the poll
returns
204indefinitely. WebAuthn returns a plausible challenge, offering onefabricated credential at login start, because a real account with no passkey
answers
401there and an empty allow-list would have sorted the decoy into thatbucket. Policy-dependent branches are reproduced, so a deployment with
email_otpdisabled still answers
403 login_method_disabledfor every identifier.A decoy derives from one HMAC over the normalised identifier, keyed with the new
optional
DECOY_SUBJECT_SECRET(falling back toAPI_SERVICE_TOKEN). The sameunknown identifier always maps to the same subject, since one that rerolled would
be an oracle by itself, and the subject is a well-formed v4 UUID that cannot be
told from a real user id without the key. Nothing is written for a decoy: they are
issued for any identifier a stranger can type, so persisting them would trade an
enumeration oracle for a way to fill the disk. There is deliberately no
decoyclaim, since anyone can decode a JWT.
A decoy's account shape is derived alongside its subject rather than fixed:
about half "have" a passkey and about half a phone, stable per identifier.
loginMethodsis filtered by what an account can actually do, so a decoy thatalways claimed the full permitted set would have made any narrower set proof that
a real account exists. A decoy left with no methods by its derived shape falls
back to the full set, since a real account with none is itself answered as a
decoy and an empty list would otherwise be the old
401in a different costume.The new optional
LOGIN_RESPONSE_FLOOR_MS(default250) holds every/loginanswer to a minimum. The real path reads more tables than the decoy path, and
identical bodies arriving at measurably different times still answer the question.
Set it above the slowest real login the deployment sees, or
0to turn it off.defineRoutenow refuses to register a route that accepts an ephemeral token anddeclares no decoy responder, so a new pre-auth endpoint cannot silently reopen
the oracle.
1dca9f7: Upgrade to Express 5.
The HTTP contract is unchanged: every behaviour Express 5 alters by default is
pinned or restored, so no caller has to adapt. Four things needed real work.
req.queryas agetter with no setter that re-reads the URL on every access, so the assignment
in
defineRoutethrew and every route with a query schema answered 404. Thevalidated query is now installed as an own property, which is what makes the
schema's coercions survive into the handler.
extendedtosimple, which wouldhave read
a[b]=1as the literal keya[b]. Pinned back toextendedso anupgrade here never silently changes how a caller's query string parses. A move
to the narrower parser stays available as a deliberate change.
undefinedrather than{}. Bodiesare validated as
{}when absent, so a body-less request still reports itsmissing fields instead of one opaque "expected object, received undefined", and
DELETE /admin/usersanswersUser not found.as before rather than throwing.a bare
*, so/console/*is now the named/console/*splat.Route params are typed through a new
RouteRequest, since Express 5 widensreq.paramstostring | string[]for the repeatable params this API does not use.429cfd2: Let a deployment choose which authenticators it will enrol.
Adds the
authenticator_policysystem config key, settable fromAUTHENTICATOR_POLICYas JSON:{ "attachment": "any" }attachmentacceptsany,platformorcross-platform.any, the default,offers both built-in authenticators and roaming security keys at registration,
which is what an agency issuing hardware keys needs. Naming one narrows the
browser picker for every registration on the instance.
The
?attachment=parameter onGET /webauthn/register/startstill works, andis now bounded by the policy: a request asking for a kind a pinned policy
excludes is refused with
400 { "error": "attachment_not_allowed" }rather thansilently overriding it. A request that agrees with the policy is fine.
Existing deployments are unaffected. The key defaults to
{ "attachment": "any" },which is the behaviour they already had.
Requires
@seamless-auth/types0.7.0, which carries the shared schema.fdbaa86: Record which authenticator a credential came from.
Credentials recorded what an authenticator can do (transports, device type,
backup state) but not what it is. The AAGUID that
@simplewebauthn/serverhandsback at registration was discarded.
It is now stored on the credential and returned on credential responses. That is
the key the FIDO Metadata Service is looked up by, the key an allow or deny list
of approved authenticators is expressed in, and what makes "which authenticator
models are deployed here" answerable.
An all-zero AAGUID is stored as reported rather than nulled. It means the
authenticator declined to identify itself, which many platform authenticators do
unless attestation is requested, and that is a different fact from never having
recorded one.
Existing credentials keep working and report no AAGUID. There is no backfill: the
value was never captured and cannot be recovered from a stored public key.
Requires
@seamless-auth/types0.12.0.6512b4e: Answer a rate-limited request with the JSON error shape.
Every other
4xxand5xxon this API is{ "error": "..." }. A429was the oneexception: express-rate-limit sends a string
messagethroughres.send, which landsas
text/html, so a client parsing error bodies as JSON got a parse failure instead ofan error.
Three limiters set that string explicitly. The other six set no message at all and
inherited the library's own string default, so they were plain text too. All nine sites,
including the JWKS limiter, now send an object and answer:
{ "error": "Too many requests, please try again later" }Behaviour change: the
429body and its content type change. Two consumers werealready coping with the text form rather than depending on it.
@seamless-auth/corecarries a
makeJsonTolerantshim inauthFetchthat names this case in its comment,and
seamless-auth-reactwas crashed by it once already. The admin dashboard maps429to fixed wording and never surfaces upstream text, so it is unaffected.
The unreachable
messageon the slow-down is removed rather than converted.express-slow-down replaces the handler with one that only delays and calls
next, so itnever answers a request and that option could never be read.
3d21962: Stop an audit write failure from silently disabling account lockout.
Failed attempts were counted by querying
auth_events, whose writes swallow everyerror. Any condition that degraded audit writes while leaving the service running
stopped failures being counted, so lockout silently stopped enforcing on every
account while authentication carried on. Disk exhaustion, a table lock, a failed
migration or connection pool exhaustion would all do it, and the absent records
are the same absent records that would have shown it happening. The practical
difference was a bounded versus an unbounded guessing attack against a numeric
OTP.
Failed attempts now go to their own
auth_failurestable, written separately fromthe audit event and read only by the lockout policy, so losing the trail no longer
loses the control.
getUserLockoutStatusrefuses rather than guessing when it cannot read thecounter: an authentication the server cannot vouch for gets the same
423alocked account gets.
Audit write failures are reported where monitoring already looks.
GET /health/statusanswers200 { "message": "System up, audit degraded", "degraded": { "audit": { … } } }for five minutes after one. The healthy body isunchanged, so anything already parsing it is unaffected, and the status stays
200because the service is still serving. That is the defined action NIST800-53 AU-5 asks for; a log line nobody reads is not.
Audit writes themselves still do not throw. 137 call sites await them, many from
inside error handlers, so failing there would turn a bookkeeping failure into a
failed request.
bbb7fac: Report an unhandled server error as 500 rather than 404.
The first error handler answered the CORS rejection and passed everything else on
with a bare
next(). Callingnext()with no argument from an error handler clearsthe error and resumes at the next regular middleware, so the 500 handler directly
below it was skipped and control landed on the 404. Any unhandled exception was
therefore reported to the caller as
404 {"error":"Not Found"}.Two consequences beyond the wrong status:
AuthEventService.requestSuspiciousevent with reason "Request to an unknownroute." Every internal error was written into the anomaly signal the dashboard and
the security views read, as suspicious behaviour by whoever happened to send the
request. That stream is now free of them.
background noise, and this masked a genuine regression through a full test run.
Behaviour change: a request that triggers an unhandled exception now answers
500 {"error":"Internal server error"}instead of404 {"error":"Not Found"}. Agenuinely unmatched route still answers 404, unchanged. Callers that retry on 5xx but
not 4xx will now retry these. No dependent needed changing: the React SDK does not
branch on 404, and the admin dashboard already maps
>= 500to a clearer message thanthe 404 text it was getting.
7356602: Bound how many sessions one user may hold at once.
max_concurrent_sessionsdefaults to no limit, so an instance that predates thesetting is unaffected. Unlimited is
nullrather than0, and0is refused,because zero would otherwise read as "no sessions allowed" and lock every user
out of a deployment that meant to remove the cap.
MAX_CONCURRENT_SESSIONSaccepts a number, or an empty value,
null,noneorunlimitedfor no cap,since a deployment template cannot easily unset a variable.
At the limit a sign-in succeeds and the user's oldest session is revoked with
revokedReason: 'concurrent_session_limit', recorded as a newsession_evictedauth event naming the session that ended. Refusing the new session instead would
lock a user out of the device in front of them until something they may not have
access to expires, which for the shared workstations this exists to protect is
the common case rather than the edge one.
Enforcement runs before the new session row is created, so the limit counts the
session about to exist: at a limit of 3 a user holding 3 ends up with 3, not 4.
Lowering the limit leaves users above it, and each converges on their next
sign-in, which evicts everything above the cap in one pass. It never throws: a
session that cannot be revoked is logged and the sign-in continues, because
failing an authentication over a housekeeping step is worse than briefly
exceeding the cap.
NIST 800-53 AC-10. Requires
@seamless-auth/types0.17.0, which publishes theconfig key.
801f679: Let a magic link request choose where the link lands.
The link was always built from one tenant-wide value,
frontend_urlfalling back tothe first configured origin. A tenant with both a web app and a mobile app could not
serve both, because a link has to arrive in one or the other.
GET /magic-linknow takes an optionalredirectUriquery parameter. A supplied valueis validated against the configured
origins, exactly the wayresolveOAuthRedirectUrialready validates an OAuth redirect, and a value outside them answers
400. The token isset as a
tokenquery parameter on the target, replacing one of that name the caller hadalready put there so the client is never handed two.
Additive. Omit the parameter and the destination is unchanged, so no existing caller
has to do anything.
The redirect matching that OAuth had inline is now
src/lib/redirectAllowlist.tsandshared by both flows, so an auth server has one place where "may we send someone here"
is decided rather than one per flow.
The allowlist is the WebAuthn
originslist because there is no dedicated one. Adestination that cannot be expressed as one of those, a custom scheme like
myapp://ora universal link on a host that is not a WebAuthn origin, needs a
magic_link_redirect_urissystem config key. That key would live in@seamless-auth/typesand needs a coordinated release, so it is deliberately left as afollow-up rather than bundled here.
e47f16f: Stop the login request body downgrading a passkey-only policy.
POST /loginaccepts apasskeyAvailablehint so a client without WebAuthnsupport is offered something it can actually complete. That hint was folded into
the decision about whether passkey was usable at all, and the passkey-only branch
was gated on the result. So a caller sending
passkeyAvailable: falseskippedthat branch and was offered
magic_link,email_otpandphone_otpinstead,which turned off
passkey_login_fallback_enabled: falsefrom the request body.The one setting whose job is to keep a passkey-holding account on passkeys could
be switched off by the account's own client.
The hint is now advisory, which is all a self-reported capability can safely be.
It can remove passkey from a set the policy already permits, and it cannot add a
weaker method to a passkey-only one.
Behaviour change. With
passkey_login_fallback_enabled: false, an accountthat holds a passkey is now offered passkey only, whatever the client reports. A
browser that genuinely cannot run the ceremony cannot sign in to such an account,
which is what passkey-only means: the previous behaviour offered email OTP to
anyone who claimed not to support passkeys. Accounts with no passkey are
unaffected and keep the configured methods.
This also closes a quieter version of the same problem. The React SDK computes
the hint asynchronously and starts from
false, so a user submitting before thatcheck resolves, or hitting the error path, sent
passkeyAvailable: falsefrom aperfectly capable browser and was silently dropped to a weaker method.
606b1a4: Advertise every credential algorithm the FIDO specification requires.
Registration relied on the SimpleWebAuthn default of
[-8, -7, -257], whichomits
RS1. FIDO Server Requirements v2.3 requires a server to implementRS1,RS256,ES256andEdDSA, so a conformance run would have flagged it.The set is now stated explicitly and ordered by preference, with
RS1last.pubKeyCredParamsis an ordered preference list, andRS1isRSASSA-PKCS1-v1_5 with SHA-1: it is offered because the specification requires
support for it, and placed last so that no authenticator with a better option
available will choose it.
Verification is pinned to the same set. It previously fell back to every
algorithm the library knows, which meant accepting a credential using something
this server never offered.
Stating the set also means a library upgrade cannot quietly change what is
advertised, which a test now pins.
Patch Changes
7ee36cf: Scan, describe and sign the published container image.
Adopters pulling
ghcr.io/fells-code/seamless-auth-apihad no way to verify whatwas inside a tag or that it came from this repository. The release workflow now:
fixable high or critical findings, and reports the findings to the security tab
registry can answer what is inside a tag and where it was built
store or rotate
Unfixed findings do not block, and neither do npm's own bundled dependencies
inside the Node base image, which the container never invokes and which no change
here can patch. Verified against
node:24-slim: without that exclusion the gatefails on four findings in npm's own tree on the first release. The application's
own dependencies are still scanned and still block, which is the part this
repository controls. A gate that blocks on something nobody can fix only trains
people to bypass it.
9e420c8: Drop the vestigial
users.challengeandusers.challengeContextcolumns.WebAuthn challenges moved to the
webauthn_challengestable, which gave them apurpose, an expiry and one-time use. These two were left behind so that release
could be rolled back without losing challenge state, and nothing has read or
written either since. Left in place they read as live state to anyone opening
src/models/users.ts.The
downrestores both nullable, which is the shape they had. It does notrestore data and does not need to: a challenge lives 300 seconds, so anything a
rollback could carry across has already expired, and the worst case is an
in-flight ceremony that the user starts again.
b3ceae2: Keep the package pre-1.0 until cutting 1.0 is a decision.
Two changesets asked for a major bump, which would have released 1.0.0 as a side
effect of landing a breaking change rather than because the code was judged ready
for it. Both are now minor, which under 0.x already signals a break, and both keep
the breaking-change warning in their body where it does the reader some good.
A CI check fails the build on any major changeset, so the next breaking change
cannot quietly reintroduce this. Delete that check in the same change that cuts
1.0.
5efcf5c: Load the conformance metadata statements the FIDO tools actually ship, and let
their attestation statements validate.
Two defects in conformance mode, both found by the first real run of the FIDO2
Conformance Test Tools:
Statements were never loaded. The tools' "DOWNLOAD SERVER METADATA" archive
unzips to a nested
metadataStatements/directory, and the loader only readJSON files at the top level of
FIDO_CONFORMANCE_METADATA_DIR. It silentlyfound none. With
requireKnownAuthenticatorset, the metadata service runs instrict mode, so every conformance authenticator was refused as unlisted and
every registration failed. The loader now recurses, so the archive can be
dropped in unedited as the documentation already promised.
Vendor attestation roots blocked their own tests. The tools sign Apple,
Android Key and SafetyNet statements with their own test roots, so validating
them against the real vendor roots could never succeed. Those preset roots are
now cleared in conformance mode, which lets the library fall back to the roots
carried in the metadata statement.
Registration options advertised an extension nobody asked for.
generateRegistrationOptionsalways appends its owncredProps, and the toolscompare the echoed extensions to the requested set for exact equality, so a
request for
{"example.extension.bool": true}came back as that pluscredPropsand failed. The requested set is now echoed verbatim. Theauthentication options path never had the problem, since the library passes
extensions through there unchanged.
All three are confined to
FIDO_CONFORMANCE_MODE, which is refused under aproduction
NODE_ENV. No deployed behaviour changes.5ccced6: Derive the default authenticator policy from the schema instead of restating it.
SYSTEM_CONFIG_DEFAULTS.authenticator_policylisted each field by hand, so afield added to
AuthenticatorPolicySchemaupstream stayed absent here untilsomebody noticed. That is not hypothetical: it is how the default fell behind
when the schema gained
attestationandrequireKnownAuthenticator, and againwhen it gained
syncedPasskeys,aaguidAllowListandaaguidDenyList.Parsing the schema with no input yields exactly the same object it produced by
hand, so nothing changes today. What changes is that the next field arrives with
the default the schema gives it rather than silently missing, and a test now
fails if the two ever disagree.
d0514db: Stop untrusted values reaching the log and the audit trail intact.
Log messages interpolate request paths, provider ids and similar caller-supplied
values through template strings across the codebase. A newline in one of those
let a caller forge a second log entry. Control characters are now escaped
centrally in the logger format, the single place every line already passes
through for redaction, rather than at each call site where one missed
interpolation reopens it.
redactSensitiveValuebuilt its output on a plain object, so a__proto__keyin audit metadata hit the prototype setter instead of creating a property: the
key vanished from the redacted output unredacted, and replaced that object's
prototype with caller-supplied content. The output is now built on a null
prototype, so the key is recorded as ordinary data.
Dev signing key generation checked for a key file and then wrote one, so two
processes starting together could both generate and both write, leaving one
signing with a key that was neither on disk nor published in JWKS. Both paths
now create the file exclusively and adopt the winner's key on losing the race.
The slug trim matches a single leading or trailing dash rather than a run. The
preceding collapse leaves no two dashes adjacent, so a run cannot occur, and
matching one made the trim backtrack over an input of many dashes for a
repetition that was never there.
682de10: Stop the test suite failing on assertions unrelated to the change under test.
vi.clearAllMocks()in each spec'sbeforeEachempties call history but leavesthe
mockResolvedValueOncequeue intact, so a value queued by one test and neverconsumed was returned to a later, unrelated one. That shifted every subsequent
queued value by a place, surfacing as a wrong status, a wrong body, or a request
that never settled and timed out. Vitest now resets mocks between tests, which is
what drains the queue.
vi.stubEnvandvi.stubGlobalwrite to the process rather than the moduleregistry, and
isolatedoes not roll those back between files, so a stubbedNODE_ENVor a stubbed globalfetchoutlived the file that set it. Both arenow restored automatically.
APP_ORIGINSmoved from a stub inmocks.tsto aplain assignment in
env.ts, since restoring stubs before every test wouldotherwise drop it after the first test of each file.
Route handlers answer the request before their fire-and-forget audit logging
settles, so supertest resolved with continuations still queued and a stray call
could land in the middle of the next test, breaking a
toHaveBeenCalledTimesora
toHaveBeenNthCalledWithon a shared mock. Those are now drained after everytest.
With the leaks closed, spec files no longer have to run one at a time:
fileParallelismis back on and the suite runs in about a sixth of the time.npm run coverageno longer forces sequential execution either.52503b3: Resolve the high severity advisories in the dependency tree.
npm audit fixcleared six high severity findings, all transitive, with nochange to
package.jsonand no change in behaviour. Two moderate advisoriesremain from
sequelize, whose only offered fix is a downgrade to version 3.