Skip to content

fix(cookies): parse an upstream cookie ttl before it reaches an adapter - #145

Merged
Bccorb merged 1 commit into
mainfrom
fix/coerce-upstream-cookie-ttl
Jul 31, 2026
Merged

fix(cookies): parse an upstream cookie ttl before it reaches an adapter#145
Bccorb merged 1 commit into
mainfrom
fix/coerce-upstream-cookie-ttl

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Third of three, and the one that stops this class of bug recurring. Not blocked on the other two: it fixes the symptom regardless of what upstream sends, and can merge first.

The bug

Registration fails on the Fastify adapter with TypeError: option maxAge is invalid: 300. The auth API sends ttl as the string "300" on its registration response. Handler results declare ttl: number but populate it from await up.json(), which is any, so nothing catches the mismatch.

From there the adapters diverge:

Adapter internal/respond.ts Result
Express maxAge: command.maxAgeSeconds * 1000 "300" * 1000 === 300000, coerced, works
Fastify maxAge: command.maxAgeSeconds string reaches cookie, Number.isInteger fails, throws

Both are correct for their own library's units. The difference is that one of them coerces as a side effect and the other does not, which is not a property either adapter should be relied on for.

The fix

applyCookies parses the lifetime once, before an adapter sees it, and uses the parsed value for all three things that derive from it: the Max-Age, the Expires, and the signed cookie's own expiry.

applyCookies is the single choke point. It handles both the handler results and the silent-refresh path in ensureCookies, in both adapters, so nothing else needs touching.

Anything that is not a positive whole number of seconds now throws with the offending value. A cookie is the session, and issuing one with a lifetime nobody can vouch for is worse than refusing the response. Previously garbage produced NaN and threw somewhere less legible anyway.

Why the parity suite missed it

Every scenario hand-writes ttl: 300 as a genuine number, so the suite agreed on input the real upstream does not send. Both adapters do agree on correct input; they only diverge on the wrong input.

Added a scenario that sends ttl as a string through /registration/register and asserts both adapters emit identical cookies. I confirmed it fails without the core change by stashing the fix, rebuilding, and re-running.

Verification

core 225 passing, express 150 passing, fastify 44 passing. Twelve new core tests cover the numeric string matching the number exactly (maxAge, expires, and the signed expiry), plus seven rejection cases.

The rest of the fix

fells-code/seamless-auth-types#27 and the matching API change stop the string being sent at all. This PR is what stops the next untyped upstream value splitting the adapters the same way.

Registration failed on Fastify with `TypeError: option maxAge is invalid: 300`.
The auth API returns ttl as the string "300" on its registration response.
Handler results declare ttl as a number but fill it from a parsed JSON body, so
nothing caught the mismatch.

From there the adapters diverged. Express multiplies the value into
milliseconds, which coerces the string to a number and hides it, so registration
has always worked there. Fastify passes the value through to `cookie`, whose
Number.isInteger check rejects a string and throws.

applyCookies now parses the lifetime once, before an adapter sees it, and uses
the parsed value for the Max-Age, the Expires, and the signed cookie's own
expiry. Anything that is not a positive whole number of seconds throws with the
offending value, rather than issuing a session cookie with a lifetime nobody can
vouch for.

The parity suite hand-wrote ttl as a number in every scenario, so it agreed on
input the real upstream does not send. It now covers a string ttl through the
registration route, and fails without this change.

The API returning a string here is a separate defect, fixed alongside this.
Parsing at the boundary is what stops the next untyped upstream value from
splitting the adapters the same way.

Verified with the core, express, and fastify suites: 225, 150, and 44 passing.
@Bccorb
Bccorb merged commit 62da4c2 into main Jul 31, 2026
2 checks passed
@Bccorb
Bccorb deleted the fix/coerce-upstream-cookie-ttl branch July 31, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant