Summary
When a pre-auth gated route is called without its cookie, ensureCookies falls through to
the silent refresh path and writes the resulting access token under the pre-auth
cookie name. A refresh can never mint an ephemeral token, so this is always wrong for the
routes that need one: it poisons the cookie and the next attempt forwards an access token
to a route the auth API gates on ephemeral.
Found while moving passkey enrollment behind the access session. Enrollment no longer hits
this because it now wants an access token anyway, but /webAuthn/login/start and
/webAuthn/login/finish still do.
Mechanism
/webAuthn/login/start and /webAuthn/login/finish require preAuthCookieName
(packages/core/src/ensureCookies.ts:61-62).
meHandler clears the pre-auth cookie on every /users/me
(packages/core/src/handlers/me.ts:30), and nothing else clears it. Any client that
polls the session, which @seamless-auth/react does on provider mount, routinely
arrives at these routes without one.
- The cookie is missing and required, so
refreshRequiredCookie runs
(ensureCookies.ts:310-315). It spends the refresh token and writes the new access
token back under cookieName, which here is seamless-ephemeral
(ensureCookies.ts:242).
checkProxyIdentity reads the incoming cookies, so that request still answers
401 "pre-auth session required" (packages/core/src/proxyRequest.ts:105-109).
- The retry now finds the cookie present, holding an access token, and forwards
Bearer <access> upstream. The auth API answers
JWT typ mismatch: expected 'ephemeral', got 'access'.
/webAuthn/login/finish is a handler rather than proxyWithIdentity, so it has no
checkProxyIdentity and reaches step 5 on the first attempt.
Why it is not caught today
The second refreshRequiredCookie call site is already guarded on the cookie being the
access one (ensureCookies.ts:343). The first is not, so it applies the same refresh to
every required cookie regardless of which token type the route can use.
Suggested fix
Guard the first call site the way the second one is: only silent-refresh when
cookieName === opts.accessCookieName. For a pre-auth gated route with no pre-auth cookie
the honest answer is 401, because nothing the adapter holds can produce the token that
route needs.
A test that a pre-auth gated route with only a refresh cookie answers 401 and asks
upstream nothing would pin it, in both adapters.
Context
This is the "probably its own bug" the reporter of
fells-code/seamless-auth-api#278 split out, and it is what produced the typ mismatch log
line in that report.
Summary
When a pre-auth gated route is called without its cookie,
ensureCookiesfalls through tothe silent refresh path and writes the resulting access token under the pre-auth
cookie name. A refresh can never mint an ephemeral token, so this is always wrong for the
routes that need one: it poisons the cookie and the next attempt forwards an access token
to a route the auth API gates on
ephemeral.Found while moving passkey enrollment behind the access session. Enrollment no longer hits
this because it now wants an access token anyway, but
/webAuthn/login/startand/webAuthn/login/finishstill do.Mechanism
/webAuthn/login/startand/webAuthn/login/finishrequirepreAuthCookieName(
packages/core/src/ensureCookies.ts:61-62).meHandlerclears the pre-auth cookie on every/users/me(
packages/core/src/handlers/me.ts:30), and nothing else clears it. Any client thatpolls the session, which
@seamless-auth/reactdoes on provider mount, routinelyarrives at these routes without one.
refreshRequiredCookieruns(
ensureCookies.ts:310-315). It spends the refresh token and writes the new accesstoken back under
cookieName, which here isseamless-ephemeral(
ensureCookies.ts:242).checkProxyIdentityreads the incoming cookies, so that request still answers401 "pre-auth session required"(packages/core/src/proxyRequest.ts:105-109).Bearer <access>upstream. The auth API answersJWT typ mismatch: expected 'ephemeral', got 'access'./webAuthn/login/finishis a handler rather thanproxyWithIdentity, so it has nocheckProxyIdentityand reaches step 5 on the first attempt.Why it is not caught today
The second
refreshRequiredCookiecall site is already guarded on the cookie being theaccess one (
ensureCookies.ts:343). The first is not, so it applies the same refresh toevery required cookie regardless of which token type the route can use.
Suggested fix
Guard the first call site the way the second one is: only silent-refresh when
cookieName === opts.accessCookieName. For a pre-auth gated route with no pre-auth cookiethe honest answer is
401, because nothing the adapter holds can produce the token thatroute needs.
A test that a pre-auth gated route with only a refresh cookie answers
401and asksupstream nothing would pin it, in both adapters.
Context
This is the "probably its own bug" the reporter of
fells-code/seamless-auth-api#278 split out, and it is what produced the
typ mismatchlogline in that report.